From de148f97731a9ca38e30d4c3f6b2c2beb564cd15 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 27 Aug 2026 12:32:10 -0700 Subject: [PATCH 001/137] add pagespeed table to redux state --- client/src/Features/UI/uiSlice.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/client/src/Features/UI/uiSlice.ts b/client/src/Features/UI/uiSlice.ts index be54eead8..f759b2957 100644 --- a/client/src/Features/UI/uiSlice.ts +++ b/client/src/Features/UI/uiSlice.ts @@ -2,7 +2,13 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; type ThemeMode = "light" | "dark"; type ChartType = "histogram" | "line"; -type TableName = "monitors" | "team" | "maintenance" | "infrastructure" | "logs"; +type TableName = + | "monitors" + | "team" + | "maintenance" + | "infrastructure" + | "logs" + | "pagespeed"; interface TableState { rowsPerPage: number; @@ -14,6 +20,7 @@ interface SidebarState { interface UIState { monitors: TableState; + pagespeed: TableState; team: TableState; maintenance: TableState; infrastructure: TableState; @@ -37,6 +44,9 @@ const initialState: UIState = { monitors: { rowsPerPage: 10, }, + pagespeed: { + rowsPerPage: 10, + }, team: { rowsPerPage: 5, }, From 7fb0e9dc702afaea81f72e7643ccf48cafdf7791 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 27 Aug 2026 13:18:02 -0700 Subject: [PATCH 002/137] new monitor list controller hook --- client/src/Hooks/useMonitorListController.ts | 207 +++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 client/src/Hooks/useMonitorListController.ts diff --git a/client/src/Hooks/useMonitorListController.ts b/client/src/Hooks/useMonitorListController.ts new file mode 100644 index 000000000..63cc4ee91 --- /dev/null +++ b/client/src/Hooks/useMonitorListController.ts @@ -0,0 +1,207 @@ +import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice"; +import { useDelete, useGet } from "@/Hooks/UseApi"; +import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions"; +import useDebounce from "@/Hooks/useDebounce"; +import type { RootState } from "@/store"; +import { + type MonitorsWithChecksResponse, + SelectableMonitorTypes, + type Monitor, + type MonitorType, +} from "@/Types/Monitor"; +import type { Tag } from "@/Types/Tag"; +import { useCallback, useMemo, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +interface MonitorListConfig { + types: MonitorType[] | "selectable"; + checksLimit: number; + refreshInterval: number; + rowsPerPageTable: TableName; + rowsPerPageDefault: number; + initialSortField?: string; // default "" +} + +export const useMonitorListController = (config: MonitorListConfig) => { + const dispatch = useDispatch(); + const rowsPerPage = useSelector( + (state: RootState) => + state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault + ); + + // Filter states + const [selectedTypes, setSelectedTypes] = useState([]); + const [selectedStatus, setSelectedStatus] = useState(""); + const [selectedState, setSelectedState] = useState(""); + const [selectedTags, setSelectedTags] = useState([]); + const [page, setPage] = useState(0); + const [search, setSearch] = useState(""); + const [sortField, setSortField] = useState(config.initialSortField ?? ""); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + const [selectedMonitor, setSelectedMonitor] = useState(null); + const debouncedSearch = useDebounce(search, 300); + + // Convert filter selections to API filter values + // Status: pass "up"/"down" directly to the API + // State: "active" -> true, "paused" -> false + const toFilterStatus = useMemo(() => { + if (selectedStatus === "up") return "up"; + if (selectedStatus === "down") return "down"; + return undefined; + }, [selectedStatus]); + + const toFilterActive = useMemo(() => { + if (selectedState === "active") return "true"; + if (selectedState === "paused") return "false"; + return undefined; + }, [selectedState]); + + // Determine field and filter for the API request + // Priority: status > isActive > search > sort + const filterLookup = new Map([ + [toFilterStatus, "status"], + [toFilterActive, "isActive"], + ]); + const activeFilter = [...filterLookup].find(([key]) => key !== undefined); + const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined); + const filter = activeFilter?.[0] || debouncedSearch || undefined; + + const effectiveTypes = useMemo(() => { + return config.types === "selectable" + ? selectedTypes.length > 0 + ? selectedTypes + : [...SelectableMonitorTypes] + : config.types; + }, [config.types, selectedTypes]); + + // Fetching + const monitorsWithChecksUrl = useMemo(() => { + const params = new URLSearchParams(); + effectiveTypes.forEach((type) => params.append("type", type)); + selectedTags.forEach((tag) => params.append("tags", tag)); + params.append("limit", String(config.checksLimit)); + if (page !== undefined) params.append("page", String(page)); + if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage)); + if (filter) params.append("filter", filter); + if (field) params.append("field", field); + if (sortOrder) params.append("order", sortOrder); + return `/monitors/team/with-checks?${params.toString()}`; + }, [ + effectiveTypes, + selectedTags, + page, + rowsPerPage, + filter, + field, + sortOrder, + config.checksLimit, + ]); + + const { + data: monitorsWithChecksData, + isLoading, + error, + refetch, + } = useGet( + monitorsWithChecksUrl, + {}, + { refreshInterval: config.refreshInterval, keepPreviousData: true } + ); + const { data: tags } = useGet("/tags/team"); + + const bulk = useBulkMonitorActions(refetch, page); + + // Delete ops + const { deleteFn, loading: isDeleting } = useDelete(); + const handleConfirmDelete = async () => { + if (!selectedMonitor) return; + await deleteFn(`/monitors/${selectedMonitor.id}`); + setSelectedMonitor(null); + refetch(); + }; + const handleCancelDelete = () => setSelectedMonitor(null); + + // Handle filters + const handleClearFilters = useCallback(() => { + setSelectedTypes([]); + setSelectedStatus(""); + setSelectedState(""); + setSelectedTags([]); + setSearch(""); + }, []); + + const handleSetRowsPerPage = (value: number) => { + dispatch(setRowsPerPage({ value, table: config.rowsPerPageTable })); + setPage(0); + }; + + // Check for active filters + const hasActiveFilters = Boolean( + selectedTypes.length > 0 || + selectedStatus || + selectedState || + selectedTags.length > 0 || + search + ); + + // Show empty state only when there are truly no monitors (not just filtered out) + // If filters are active and count is 0, pass 1 to prevent empty state fallback + + const { monitors, summary, count } = monitorsWithChecksData ?? { + monitors: null, + summary: null, + count: 0, + }; + + const effectiveTotalCount = + hasActiveFilters && (summary?.totalMonitors ?? 0) === 0 + ? 1 + : (summary?.totalMonitors ?? 0); + + return { + // Data + monitors, + summary, + count, + tags, + isLoading, + error, + refetch, + effectiveTotalCount, + hasActiveFilters, + + // Filter state and setters + selectedTypes, + setSelectedTypes, + selectedStatus, + setSelectedStatus, + selectedState, + setSelectedState, + selectedTags, + setSelectedTags, + search, + setSearch, + handleClearFilters, + + // Paging and Sorting + page, + setPage, + rowsPerPage, + handleSetRowsPerPage, + sortField, + setSortField, + sortOrder, + setSortOrder, + + // Delete + selectedMonitor, + setSelectedMonitor, + isDialogOpen: Boolean(selectedMonitor), + isDeleting, + handleConfirmDelete, + handleCancelDelete, + + // Rendered only when config.bulkActions + ...bulk, + }; +}; From e9dca23664e7fe9abe3d39965c3afa3c8beba2da Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 27 Aug 2026 13:53:28 -0700 Subject: [PATCH 003/137] add base monitor list page --- .../Components/monitors/MonitorListPage.tsx | 155 ++++++++++++++++++ client/src/Components/monitors/index.tsx | 1 + client/src/Hooks/useMonitorListController.ts | 2 + 3 files changed, 158 insertions(+) create mode 100644 client/src/Components/monitors/MonitorListPage.tsx diff --git a/client/src/Components/monitors/MonitorListPage.tsx b/client/src/Components/monitors/MonitorListPage.tsx new file mode 100644 index 000000000..2cadb2b81 --- /dev/null +++ b/client/src/Components/monitors/MonitorListPage.tsx @@ -0,0 +1,155 @@ +import { HeaderCreate } from "@/Components/common"; +import { ColoredLabel, MonitorBasePageWithStates } from "@/Components/design-elements"; +import { BulkActionsBar, ControlsFilter } from "@/Components/monitors"; +import { HeaderMonitorsSummary } from "@/Components/monitors/HeaderMonitorsSummary"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; +import type { MonitorListController } from "@/Hooks/useMonitorListController"; +import { useMediaQuery, useTheme } from "@mui/material"; +import { SPACING } from "@/Utils/Theme/constants"; + +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { TextField, Button, Dialog } from "@/Components/inputs"; +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Pause, Play } from "lucide-react"; + +interface MonitorListPageProps { + headerKey: string; + page: string; + actionLink: string; + controller: MonitorListController; + bulkActions?: boolean; + showTypeFilter?: boolean; + summaryProps?: { showBreached?: boolean }; + priorityFallback?: ReactNode; + extraLoading?: boolean; + extraError?: unknown; + children: ReactNode; +} + +// Base monitor list page, all monitors pages are nearly identical, but with different tables. + +export const MonitorListPage = ({ + headerKey, + page, + actionLink, + controller: c, + bulkActions, + showTypeFilter, + summaryProps, + priorityFallback, + extraLoading, + extraError, + children, +}: MonitorListPageProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const isAdmin = useIsAdmin(); + const isLoading = c.isLoading || Boolean(extraLoading); + + return ( + + + + + + { + c.setSearch(event.target.value); + }} + /> + + {c.selectedTags.length > 0 && ( + + + {t("pages.uptime.filters.activeTags")} + + {c.selectedTags.map((tagId) => { + const tag = c.tags?.find((t) => t.id === tagId); + if (!tag) return null; + return ( + + ); + })} + + )} + {bulkActions && !isLoading && ( + + + + + )} + {children} + + + ); +}; diff --git a/client/src/Components/monitors/index.tsx b/client/src/Components/monitors/index.tsx index 46532706f..cf87eb074 100644 --- a/client/src/Components/monitors/index.tsx +++ b/client/src/Components/monitors/index.tsx @@ -15,3 +15,4 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip"; export * from "./charts/HistogramInfrastructure"; export * from "./HeaderMonitorsSummary"; export * from "./BulkActionsBar"; +export * from "./MonitorListPage"; diff --git a/client/src/Hooks/useMonitorListController.ts b/client/src/Hooks/useMonitorListController.ts index 63cc4ee91..cd6852594 100644 --- a/client/src/Hooks/useMonitorListController.ts +++ b/client/src/Hooks/useMonitorListController.ts @@ -205,3 +205,5 @@ export const useMonitorListController = (config: MonitorListConfig) => { ...bulk, }; }; + +export type MonitorListController = ReturnType; From e444d5fbe6a338d94e5debf197068720979cfd62 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 27 Aug 2026 13:54:40 -0700 Subject: [PATCH 004/137] decycle imports --- client/src/Components/monitors/MonitorListPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/Components/monitors/MonitorListPage.tsx b/client/src/Components/monitors/MonitorListPage.tsx index 2cadb2b81..8244f3875 100644 --- a/client/src/Components/monitors/MonitorListPage.tsx +++ b/client/src/Components/monitors/MonitorListPage.tsx @@ -1,6 +1,7 @@ import { HeaderCreate } from "@/Components/common"; import { ColoredLabel, MonitorBasePageWithStates } from "@/Components/design-elements"; -import { BulkActionsBar, ControlsFilter } from "@/Components/monitors"; +import { BulkActionsBar } from "@/Components/monitors/BulkActionsBar"; +import { ControlsFilter } from "@/Components/monitors/ControlsFilter"; import { HeaderMonitorsSummary } from "@/Components/monitors/HeaderMonitorsSummary"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; import type { MonitorListController } from "@/Hooks/useMonitorListController"; From 0c9334071fc13d379db36e06edbdbebcac7e82e6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 27 Aug 2026 14:28:29 -0700 Subject: [PATCH 005/137] refactor monitor pages --- .../Pages/Infrastructure/Monitors/index.tsx | 292 ++--------------- client/src/Pages/PageSpeed/Monitors/index.tsx | 236 ++------------ client/src/Pages/Uptime/Monitors/index.tsx | 307 ++---------------- client/src/store.ts | 3 + 4 files changed, 94 insertions(+), 744 deletions(-) diff --git a/client/src/Pages/Infrastructure/Monitors/index.tsx b/client/src/Pages/Infrastructure/Monitors/index.tsx index aa41d2031..5346f353c 100644 --- a/client/src/Pages/Infrastructure/Monitors/index.tsx +++ b/client/src/Pages/Infrastructure/Monitors/index.tsx @@ -1,274 +1,44 @@ -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import useMediaQuery from "@mui/material/useMediaQuery"; -import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements"; -import { HeaderCreate } from "@/Components/common"; -import { - ControlsFilter, - HeaderMonitorsSummary, - BulkActionsBar, -} from "@/Components/monitors"; -import { TextField, Dialog, Button } from "@/Components/inputs"; -import { Play, Pause } from "lucide-react"; +import { MonitorListPage } from "@/Components/monitors"; +import { InfraMonitorsTable } from "@/Pages/Infrastructure/Monitors/Components/MonitorsTable"; -import { useGet, useDelete } from "@/Hooks/UseApi"; -import { useIsAdmin } from "@/Hooks/useIsAdmin"; -import type { Monitor, MonitorsWithChecksResponse } from "@/Types/Monitor"; -import type { Tag } from "@/Types/Tag"; -import { useTheme } from "@mui/material"; -import { SPACING } from "@/Utils/Theme/constants"; -import { useState, useCallback, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { useSelector, useDispatch } from "react-redux"; -import { setRowsPerPage } from "@/Features/UI/uiSlice.js"; -import type { RootState } from "@/Types/state"; -import { InfraMonitorsTable } from "./Components/MonitorsTable"; -import useDebounce from "@/Hooks/useDebounce"; -import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions"; +import { useMonitorListController } from "@/Hooks/useMonitorListController"; const InfrastructureMonitors = () => { - const { t } = useTranslation(); - const theme = useTheme(); - const isSmall = useMediaQuery(theme.breakpoints.down("md")); - const isAdmin = useIsAdmin(); - const dispatch = useDispatch(); - - // Redux state - const rowsPerPage = useSelector( - (state: RootState) => state.ui?.infrastructure?.rowsPerPage ?? 5 - ); - - // Local state - const [selectedStatus, setSelectedStatus] = useState(""); - const [selectedState, setSelectedState] = useState(""); - const [selectedTags, setSelectedTags] = useState([]); - const [search, setSearch] = useState(""); - const [page, setPage] = useState(0); - const [sortField, setSortField] = useState(""); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); - const [selectedMonitor, setSelectedMonitor] = useState(null); - - const { data: tags } = useGet("/tags/team"); - - const isDialogOpen = Boolean(selectedMonitor); - - const debouncedSearch = useDebounce(search, 300); - - const handleClearFilters = useCallback(() => { - setSelectedStatus(""); - setSelectedState(""); - setSelectedTags([]); - setSearch(""); - }, []); - - // Convert filter selections to API filter values - const toFilterStatus = useMemo(() => { - if (selectedStatus === "up") return "true"; - if (selectedStatus === "down") return "false"; - return undefined; - }, [selectedStatus]); - - const toFilterActive = useMemo(() => { - if (selectedState === "active") return "true"; - if (selectedState === "paused") return "false"; - return undefined; - }, [selectedState]); - - // Determine field and filter for the API request - // Priority: status > isActive > search - const filterLookup = new Map([ - [toFilterStatus, "status"], - [toFilterActive, "isActive"], - ]); - const activeFilter = [...filterLookup].find(([key]) => key !== undefined); - const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined); - const filter = activeFilter?.[0] || debouncedSearch || undefined; - - // Build URL for monitors with checks - const monitorsWithChecksUrl = useMemo(() => { - const params = new URLSearchParams(); - params.append("type", "hardware"); - selectedTags.forEach((tagId) => params.append("tags", tagId)); - params.append("limit", "1"); - if (page !== undefined) params.append("page", String(page)); - if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage)); - if (filter) params.append("filter", filter); - if (field) params.append("field", field); - if (sortOrder) params.append("order", sortOrder); - return `/monitors/team/with-checks?${params.toString()}`; - }, [page, rowsPerPage, filter, field, sortOrder, selectedTags]); - - const { - data: monitorsWithChecksData, - isLoading: monitorsWithChecksLoading, - error: monitorsWithChecksError, - refetch, - } = useGet( - monitorsWithChecksUrl, - {}, - { refreshInterval: 5000, keepPreviousData: true } - ); - - const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 }; - const isLoading = monitorsWithChecksLoading; - - // Bulk actions - const { - selectedRows, - setSelectedRows, - handleBulkPause, - handleBulkResume, - handleCancelSelection, - } = useBulkMonitorActions(refetch, page); - - // Check if any filters are active - const hasActiveFilters = Boolean( - selectedStatus || selectedState || selectedTags.length > 0 || search - ); - - // Show empty state only when there are truly no monitors (not just filtered out) - const effectiveTotalCount = - hasActiveFilters && (summary?.totalMonitors ?? 0) === 0 - ? 1 - : (summary?.totalMonitors ?? 0); - - // Delete hook - const { deleteFn, loading: isDeleting } = useDelete(); - - const handleConfirm = async () => { - if (!selectedMonitor) return; - await deleteFn(`/monitors/${selectedMonitor.id}`); - setSelectedMonitor(null); - refetch(); - }; - - const handleCancel = () => { - setSelectedMonitor(null); - }; - + const c = useMonitorListController({ + types: ["hardware"], + checksLimit: 1, + refreshInterval: 5000, + rowsPerPageTable: "infrastructure", + rowsPerPageDefault: 5, + }); return ( - - - - - - { - setSearch(event.target.value); - }} - /> - - - {selectedTags.length > 0 && ( - - - {t("pages.uptime.filters.activeTags")} - - {selectedTags.map((tagId) => { - const tag = tags?.find((t) => t.id === tagId); - if (!tag) return null; - return ( - - ); - })} - - )} - - {!isLoading && ( - - - - - )} - { - dispatch( - setRowsPerPage({ - value, - table: "infrastructure", - }) - ); - setPage(0); - }} - selectedRows={selectedRows} - onSelectionChange={setSelectedRows} + monitors={c.monitors || []} + tags={c.tags || []} + refetch={c.refetch} + setSelectedMonitor={c.setSelectedMonitor} + count={c.count || 0} + page={c.page} + setPage={c.setPage} + rowsPerPage={c.rowsPerPage} + sortField={c.sortField} + setSortField={c.setSortField} + sortOrder={c.sortOrder} + setSortOrder={c.setSortOrder} + setRowsPerPage={c.handleSetRowsPerPage} + selectedRows={c.selectedRows} + onSelectionChange={c.setSelectedRows} /> - - + ); }; diff --git a/client/src/Pages/PageSpeed/Monitors/index.tsx b/client/src/Pages/PageSpeed/Monitors/index.tsx index b9d0b49b1..3eb597fa6 100644 --- a/client/src/Pages/PageSpeed/Monitors/index.tsx +++ b/client/src/Pages/PageSpeed/Monitors/index.tsx @@ -1,229 +1,57 @@ -import { - MonitorBasePageWithStates, - PageSpeedKeyPriorityFallback, - ColoredLabel, -} from "@/Components/design-elements"; -import { Dialog, TextField } from "@/Components/inputs"; -import { HeaderCreate } from "@/Components/common"; +import { PageSpeedKeyPriorityFallback } from "@/Components/design-elements"; import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable"; -import type { Monitor } from "@/Types/Monitor"; -import type { Tag } from "@/Types/Tag"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import useMediaQuery from "@mui/material/useMediaQuery"; -import { useTheme } from "@mui/material"; -import { SPACING } from "@/Utils/Theme/constants"; - -import { useTranslation } from "react-i18next"; -import { useCallback, useMemo, useState } from "react"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; -import { useGet, useDelete } from "@/Hooks/UseApi"; -import useDebounce from "@/Hooks/useDebounce"; -import type { MonitorsWithChecksResponse } from "@/Types/Monitor"; +import { useGet } from "@/Hooks/UseApi"; import type { AppSettingsResponse } from "@/Types/Settings"; -import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors"; +import { MonitorListPage } from "@/Components/monitors"; +import { useMonitorListController } from "@/Hooks/useMonitorListController"; const PageSpeedMonitorsPage = () => { - const { t } = useTranslation(); - const theme = useTheme(); - const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isAdmin = useIsAdmin(); - const { deleteFn, loading: isDeleting } = useDelete(); - - const settingsUrl = "/settings"; const { data: settingsData, isLoading: settingsIsLoading, error: settingsError, - } = useGet(settingsUrl); + } = useGet("/settings"); - const [selectedMonitor, setSelectedMonitor] = useState(null); - const isDialogOpen = Boolean(selectedMonitor); - const [sortField, setSortField] = useState("name"); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(10); - const [selectedTags, setSelectedTags] = useState([]); - const [selectedStatus, setSelectedStatus] = useState(""); - const [selectedState, setSelectedState] = useState(""); - const [search, setSearch] = useState(""); - - const debouncedSearch = useDebounce(search, 300); - - // Status: pass "up"/"down" directly to the API - // State: "active" -> true, "paused" -> false - const toFilterStatus = useMemo(() => { - if (selectedStatus === "up") return "up"; - if (selectedStatus === "down") return "down"; - return undefined; - }, [selectedStatus]); - - const toFilterActive = useMemo(() => { - if (selectedState === "active") return "true"; - if (selectedState === "paused") return "false"; - return undefined; - }, [selectedState]); - - // Priority: status > isActive > search > sort - const filterLookup = new Map([ - [toFilterStatus, "status"], - [toFilterActive, "isActive"], - ]); - const activeFilter = [...filterLookup].find(([key]) => key !== undefined); - const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined); - const filter = activeFilter?.[0] || debouncedSearch; - - const { data: tags } = useGet("/tags/team"); - - const monitorsWithChecksUrl = useMemo(() => { - const params = new URLSearchParams(); - params.append("type", "pagespeed"); - selectedTags.forEach((tagId) => params.append("tags", tagId)); - params.append("limit", "25"); - if (page !== undefined) params.append("page", String(page)); - if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage)); - if (filter) params.append("filter", filter); - if (field) params.append("field", field); - if (sortOrder) params.append("order", sortOrder); - return `/monitors/team/with-checks?${params.toString()}`; - }, [page, rowsPerPage, sortOrder, selectedTags, filter, field]); - - const { - data: monitorsData, - isLoading: monitorsIsLoading, - error: monitorsError, - refetch, - } = useGet( - monitorsWithChecksUrl, - {}, - { refreshInterval: 30000 } - ); - - const monitors = monitorsData?.monitors; - const monitorsCount = monitorsData?.count ?? 0; - const summary = monitorsData?.summary ?? null; - - const isLoading = monitorsIsLoading || settingsIsLoading; + const c = useMonitorListController({ + types: ["pagespeed"], + checksLimit: 25, + refreshInterval: 30000, + rowsPerPageTable: "pagespeed", + rowsPerPageDefault: 10, + initialSortField: "name", + }); const showApiKeyWarning = isAdmin && settingsData && !settingsData.pagespeedKeySet; - const handleConfirm = async () => { - if (!selectedMonitor) return; - await deleteFn(`/monitors/${selectedMonitor.id}`); - setSelectedMonitor(null); - refetch(); - }; - - const handleCancel = () => { - setSelectedMonitor(null); - }; - - const handleClearFilters = useCallback(() => { - setSelectedStatus(""); - setSelectedState(""); - setSelectedTags([]); - setSearch(""); - }, []); - - const hasActiveFilters = Boolean( - selectedStatus || selectedState || selectedTags.length > 0 || search - ); - - const effectiveTotalCount = - hasActiveFilters && (summary?.totalMonitors ?? 0) === 0 - ? 1 - : (summary?.totalMonitors ?? 0); - return ( - : undefined} > - - - - - - { - setSearch(event.target.value); - }} - /> - - - {selectedTags.length > 0 && ( - - - {t("pages.uptime.filters.activeTags")} - - {selectedTags.map((tagId) => { - const tag = tags?.find((t) => t.id === tagId); - if (!tag) return null; - return ( - - ); - })} - - )} - - - + ); }; diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx index 021e02ac5..f7664c1b7 100644 --- a/client/src/Pages/Uptime/Monitors/index.tsx +++ b/client/src/Pages/Uptime/Monitors/index.tsx @@ -1,294 +1,43 @@ -import { - ControlsFilter, - HeaderMonitorsSummary, - BulkActionsBar, -} from "@/Components/monitors"; -import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements"; -import { TextField, Dialog, Button } from "@/Components/inputs"; -import Stack from "@mui/material/Stack"; +import { MonitorListPage } from "@/Components/monitors"; import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable"; -import { HeaderCreate } from "@/Components/common"; -import { Play, Pause } from "lucide-react"; -import Typography from "@mui/material/Typography"; -import { useTranslation } from "react-i18next"; -import useMediaQuery from "@mui/material/useMediaQuery"; -import { useGet, useDelete } from "@/Hooks/UseApi"; -import type { Monitor, MonitorType, MonitorsWithChecksResponse } from "@/Types/Monitor"; -import type { Tag } from "@/Types/Tag"; -import { useState, useMemo, useCallback } from "react"; -import { useSelector, useDispatch } from "react-redux"; -import { setRowsPerPage } from "@/Features/UI/uiSlice.js"; -import { useIsAdmin } from "@/Hooks/useIsAdmin"; -import type { RootState } from "@/Types/state"; -import { useTheme } from "@mui/material"; -import useDebounce from "@/Hooks/useDebounce"; -import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions"; -import { SPACING } from "@/Utils/Theme/constants"; +import { useMonitorListController } from "@/Hooks/useMonitorListController"; const UptimeMonitorsPage = () => { - const { t } = useTranslation(); - const theme = useTheme(); - const isSmall = useMediaQuery(theme.breakpoints.down("md")); - const dispatch = useDispatch(); - const isAdmin = useIsAdmin(); - // Redux state - const rowsPerPage = useSelector( - (state: RootState) => state.ui?.monitors?.rowsPerPage ?? 10 - ); - - // Local state - const [selectedTypes, setSelectedTypes] = useState([]); - const [selectedStatus, setSelectedStatus] = useState(""); - const [selectedState, setSelectedState] = useState(""); - const [selectedTags, setSelectedTags] = useState([]); - const [page, setPage] = useState(0); - const [search, setSearch] = useState(""); - const [sortField, setSortField] = useState(""); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); - const [selectedMonitor, setSelectedMonitor] = useState(null); - - const isDialogOpen = Boolean(selectedMonitor); - - const debouncedSearch = useDebounce(search, 300); - - // Convert filter selections to API filter values - // Status: pass "up"/"down" directly to the API - // State: "active" -> true, "paused" -> false - const toFilterStatus = useMemo(() => { - if (selectedStatus === "up") return "up"; - if (selectedStatus === "down") return "down"; - return undefined; - }, [selectedStatus]); - - const toFilterActive = useMemo(() => { - if (selectedState === "active") return "true"; - if (selectedState === "paused") return "false"; - return undefined; - }, [selectedState]); - - // Determine field and filter for the API request - // Priority: status > isActive > search > sort - const filterLookup = new Map([ - [toFilterStatus, "status"], - [toFilterActive, "isActive"], - ]); - const activeFilter = [...filterLookup].find(([key]) => key !== undefined); - const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined); - const filter = activeFilter?.[0] || debouncedSearch; - - // Default to all types when none selected - const effectiveTypes = - selectedTypes.length > 0 - ? selectedTypes - : ["http", "ping", "docker", "port", "game", "grpc", "websocket", "dns"]; - - // Build URL for monitors with checks - const monitorsWithChecksUrl = useMemo(() => { - const params = new URLSearchParams(); - effectiveTypes.forEach((type) => params.append("type", type)); - selectedTags.forEach((tagId) => params.append("tags", tagId)); - params.append("limit", "25"); - if (page !== undefined) params.append("page", String(page)); - if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage)); - if (filter) params.append("filter", filter); - if (field) params.append("field", field); - if (sortOrder) params.append("order", sortOrder); - return `/monitors/team/with-checks?${params.toString()}`; - }, [effectiveTypes, selectedTags, page, rowsPerPage, filter, field, sortOrder]); - - const { - data: monitorsWithChecksData, - isLoading: monitorsWithChecksLoading, - error: monitorsWithChecksError, - refetch, - } = useGet( - monitorsWithChecksUrl, - {}, - { refreshInterval: 5000, keepPreviousData: true } - ); - - const { data: tags } = useGet("/tags/team"); - - const { - monitors: monitorsWithChecks, - summary, - count, - } = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 }; - - // Bulk actions - const { - selectedRows, - setSelectedRows, - handleBulkPause, - handleBulkResume, - handleCancelSelection, - } = useBulkMonitorActions(refetch, page); - - // Delete hook - const { deleteFn, loading: isDeleting } = useDelete(); - - // Handlers - const handleClearFilters = useCallback(() => { - setSelectedTypes([]); - setSelectedStatus(""); - setSelectedState(""); - setSelectedTags([]); - setSearch(""); - }, []); - - const handleConfirm = async () => { - if (!selectedMonitor) return; - await deleteFn(`/monitors/${selectedMonitor.id}`); - setSelectedMonitor(null); - refetch(); - }; - - const handleCancel = () => { - setSelectedMonitor(null); - }; - - const isLoading = monitorsWithChecksLoading; - - // Check if any filters are active - const hasActiveFilters = Boolean( - selectedTypes.length > 0 || - selectedStatus || - selectedState || - selectedTags.length > 0 || - search - ); - - // Show empty state only when there are truly no monitors (not just filtered out) - // If filters are active and count is 0, pass 1 to prevent empty state fallback - const effectiveTotalCount = - hasActiveFilters && (summary?.totalMonitors ?? 0) === 0 - ? 1 - : (summary?.totalMonitors ?? 0); + const c = useMonitorListController({ + types: "selectable", + checksLimit: 25, + refreshInterval: 5000, + rowsPerPageTable: "monitors", + rowsPerPageDefault: 10, + }); return ( - - - - - - - - { - setSearch(event.target.value); - }} - /> - - - {selectedTags.length > 0 && ( - - - {t("pages.uptime.filters.activeTags")} - - {selectedTags.map((tagId) => { - const tag = tags?.find((t) => t.id === tagId); - if (!tag) return null; - return ( - - ); - })} - - )} - - {!isLoading && ( - - - - - )} - { - dispatch( - setRowsPerPage({ - value: rowsPerPage, - table: "monitors", - }) - ); - setPage(0); - }} - selectedRows={selectedRows} - onSelectionChange={setSelectedRows} + monitors={c.monitors || []} + tags={c.tags || []} + refetch={c.refetch} + setSelectedMonitor={c.setSelectedMonitor} + count={c.count || 0} + page={c.page} + setPage={c.setPage} + rowsPerPage={c.rowsPerPage} + sortField={c.sortField} + setSortField={c.setSortField} + sortOrder={c.sortOrder} + setSortOrder={c.setSortOrder} + setRowsPerPage={c.handleSetRowsPerPage} + selectedRows={c.selectedRows} + onSelectionChange={c.setSelectedRows} /> - - + ); }; diff --git a/client/src/store.ts b/client/src/store.ts index 9e064b55f..d2a1b96d8 100644 --- a/client/src/store.ts +++ b/client/src/store.ts @@ -2,6 +2,7 @@ import { configureStore, combineReducers } from "@reduxjs/toolkit"; import authReducer from "@/Features/Auth/authSlice"; import uiReducer from "@/Features/UI/uiSlice"; import storage from "redux-persist/lib/storage"; +import autoMergeLevel2 from "redux-persist/lib/stateReconciler/autoMergeLevel2"; import { persistReducer, persistStore, @@ -24,6 +25,8 @@ const persistConfig = { storage, whitelist: ["auth", "ui"], transforms: [authTransform], + // Merge two levels deep on rehydrate so a slice key added in a new release keeps initial value + stateReconciler: autoMergeLevel2, }; const rootReducer = combineReducers({ From 96b2266a9df401ec3a3e9cb62e20af91ae7f3cb4 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Fri, 28 Aug 2026 10:33:28 -0400 Subject: [PATCH 006/137] fix(infrastructure): wrap gauge row so every disk stays reachable The gauge row on the infrastructure details page laid out memory, CPU and one gauge per disk in a single flex row with no wrapping and no overflow handling. Each gauge is capped at 260px, so on a host with enough disks the row ran past the content column. RootLayout sets overflow: "hidden" on that column, so the excess was clipped outright rather than becoming scrollable - the later disks rendered but could not be reached by any means. Three changes, all scoped to this row: flexWrap moves the overflow onto additional rows. useFlexGap is required alongside it. MUI's Stack implements `spacing` as `& > :not(style) ~ :not(style) { marginLeft }`, and that selector is DOM-order based rather than line-aware, so the first card of each wrapped row would carry a spurious 16px left margin and misalign against the row above. useFlexGap switches to a real flex gap. flexBasis on the children is what actually makes the line break. BaseChart sets `flex: 1`, which expands to `flex: 1 1 0%`; wrapping is decided by the sum of items' hypothetical main sizes, so with a zero basis that sum is 0 and the row never overflows. The cards would instead shrink to min-content (roughly the 70px gauge plus the widest label) and stay on one cramped line. Giving each child a 260px basis makes the row wrap at the intended card width. The basis is applied through the row's own `& > *` selector rather than by changing BaseChart, which has eight other consumers that default to maxWidth: "100%" and would have had their layout altered. The repeated 260 literals are now a named constant. Only affects viewports at or above the md breakpoint; below it the stack is already a column, where flexWrap is inert. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- .../Details/Components/Gauges.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx b/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx index e2b053aef..2e9e88a0f 100644 --- a/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx +++ b/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx @@ -8,6 +8,9 @@ import { useTheme } from "@mui/material"; import useMediaQuery from "@mui/material/useMediaQuery"; import type { CheckSnapshot } from "@/Types/Check"; +// Width each gauge card aims for, and the basis the wrapping row breaks on. +const GAUGE_MAX_WIDTH = 260; + export const InfraDetailsGauges = ({ snapshot, }: { @@ -26,10 +29,19 @@ export const InfraDetailsGauges = ({ direction={isSmall ? "column" : "row"} spacing={theme.spacing(8)} alignItems={"stretch"} + flexWrap={"wrap"} + useFlexGap + sx={{ + // The gauge cards are flex: 1 1 0, so a wrapping row would never + // overflow and they would squash to min-content instead of breaking + // the line. Give each one its own width as the basis so the row wraps + // at the intended card size. + "& > *": { flexBasis: GAUGE_MAX_WIDTH }, + }} > Date: Fri, 28 Aug 2026 11:22:29 -0400 Subject: [PATCH 007/137] fix(infrastructure): clamp gauge arc and fix disk key fallback Two small pre-existing bugs in the infrastructure gauges. Arc overdraw. Gauge computed strokeLength from the raw `progress` prop while the label and the fill colour both used `progressWithinRange`, the value clamped to 0-100. A reading above 100% therefore swept the arc past a full circle - the offset (circumference - strokeLength) went negative - while the label still read "100.0%". A negative reading drew the arc backwards for the same reason. strokeLength now uses the clamped value, so out-of-range input stops at exactly one turn and matches what the label and colour already showed. Values in range are unaffected. The effect that animates the arc also listed `progress` in its dependency array while only reading `strokeLength`, so it re-ran the 100ms animation on changes that could not alter the arc (105% to 110%, say). Now that strokeLength is derived from the clamped value, the stale dependency is dropped. Disk key fallback. `disk?.device || 0 + idx` parses as `disk?.device || (0 + idx)` because + binds tighter than ||, so the fallback was just `idx` and the leading 0 did nothing. It happened to produce unique keys, but by accident rather than intent, and a disk whose device is the empty string collided with index 0. Replaced with a `disk-${idx}` template so the intent is explicit. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Components/design-elements/Gauge.tsx | 9 ++++++--- .../Pages/Infrastructure/Details/Components/Gauges.tsx | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/client/src/Components/design-elements/Gauge.tsx b/client/src/Components/design-elements/Gauge.tsx index 4130f67a7..d53ce4da5 100644 --- a/client/src/Components/design-elements/Gauge.tsx +++ b/client/src/Components/design-elements/Gauge.tsx @@ -39,9 +39,12 @@ export const Gauge = forwardRef(function Gauge( () => ({ circumference: 2 * Math.PI * radius, totalSize: radius * 2 + strokeWidth * 2, - strokeLength: (progress / 100) * (2 * Math.PI * radius), + // Uses the clamped value, like the label and the fill color: an + // out-of-range reading would otherwise sweep the arc past a full circle + // while the label read 100%. + strokeLength: (progressWithinRange / 100) * (2 * Math.PI * radius), }), - [radius, strokeWidth, progress] + [radius, strokeWidth, progressWithinRange] ); const [offset, setOffset] = useState(circumference); @@ -52,7 +55,7 @@ export const Gauge = forwardRef(function Gauge( }, 100); return () => clearTimeout(timer); - }, [progress, circumference, strokeLength]); + }, [circumference, strokeLength]); const fillColor = getInfraGaugeColor(progressWithinRange, theme); diff --git a/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx b/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx index e2b053aef..75741e6c2 100644 --- a/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx +++ b/client/src/Pages/Infrastructure/Details/Components/Gauges.tsx @@ -48,7 +48,7 @@ export const InfraDetailsGauges = ({ {snapshot?.disk?.map((disk, idx) => { return ( Date: Fri, 28 Aug 2026 12:44:14 -0400 Subject: [PATCH 008/137] fix(infrastructure): show the container-namespace notice when it applies The network tab warns when Capture can only see a container's own network namespace, but the condition required EVERY reported interface to be named lo, lo0 or docker0. A containerised Capture usually also reports the container's veth under a host-looking name (eth0, ens3, ...), so the common case failed that test and the notice never appeared - leaving those users with plausible-but-meaningless traffic and nothing explaining why. Widening it to "any container-only name present" would have been wrong in the other direction: a host-level Capture on a machine running Docker legitimately reports docker0 alongside its real NIC, and would then have been warned incorrectly. Interface names alone cannot separate the two cases. What does is that in the container case nothing carries real traffic, while a genuine host has a busy NIC. The notice now fires when a container-only interface is present AND every interface is idle, using a 1 KB/s threshold - real NICs sit orders of magnitude above it, a container's veth sits at or near zero. Checked against six scenarios: the reported case (lo + ens3 + docker0, all near zero) now warns; a host with a busy NIC plus an idle docker0 does not; a fully namespaced container still warns as before; and a quiet host with no container-only names stays silent. The notice text is updated to match, since it previously described only the all-container-names case and did not explain that a host-looking interface can be the container's own. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- .../Details/Components/ChartsNetwork.tsx | 46 ++++++++++++++++--- client/src/locales/en.json | 2 +- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx b/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx index c84a327fa..dbe78335e 100644 --- a/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx +++ b/client/src/Pages/Infrastructure/Details/Components/ChartsNetwork.tsx @@ -63,19 +63,51 @@ const getChartConfigs = ( return configs; }; -// Loopback / bridge names Capture sees when it can only observe the container's -// own network namespace. When ALL reported interfaces match this set we assume -// the "no real interfaces visible" case and surface the notice. +// Loopback / bridge names Capture reports when it can only observe the +// container's own network namespace. const CONTAINER_ONLY_IFACE_NAMES = new Set(["lo", "lo0", "docker0"]); +// Below this, an interface is carrying no traffic worth charting. Real host +// NICs sit orders of magnitude above it; a container's veth sits at or near +// zero. +const IDLE_INTERFACE_BYTES_PER_SECOND = 1024; + +const interfaceIsIdle = (checks: HardwareCheckStats[], name: string): boolean => + checks.every((check) => { + const iface = check.net?.find((candidate) => candidate.name === name); + if (!iface) { + return true; + } + return ( + (iface.bytesSentPerSecond || 0) < IDLE_INTERFACE_BYTES_PER_SECOND && + (iface.deltaBytesRecv || 0) < IDLE_INTERFACE_BYTES_PER_SECOND + ); + }); + +// Capture running inside a container sees only that container's network +// namespace. It reports the loopback and bridge names above, and usually also +// the container's own veth under a host-looking name (eth0, ens3, ...) - so +// requiring EVERY interface to be container-only missed the common case, and +// those users saw plausible-but-meaningless traffic with no explanation. +// +// Interface names alone cannot separate the two cases: a host-level agent on a +// machine running Docker legitimately reports docker0 too. What distinguishes +// them is that in the container case nothing carries real traffic. So the +// notice fires when a container-only interface is present AND every interface +// is idle, which leaves a genuine host reporting docker0 alongside a busy NIC +// untouched. const onlyContainerVisibleInterfaces = (checks: HardwareCheckStats[]): boolean => { const named = new Set(); checks.forEach((c) => c.net?.forEach((iface) => named.add(iface.name))); - if (named.size === 0) return false; - for (const name of named) { - if (!CONTAINER_ONLY_IFACE_NAMES.has(name)) return false; + if (named.size === 0) { + return false; } - return true; + + const names = [...named]; + if (!names.some((name) => CONTAINER_ONLY_IFACE_NAMES.has(name))) { + return false; + } + return names.every((name) => interfaceIsIdle(checks, name)); }; export const InfraNetworkCharts = ({ diff --git a/client/src/locales/en.json b/client/src/locales/en.json index e82f84949..b9881b4e6 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -926,7 +926,7 @@ "memory": "Memory usage", "netBytesRecv": "{{name}} - Bytes Received", "netBytesSent": "{{name}} - Bytes Sent", - "loopbackOnlyNotice": "Capture is only reporting loopback or container-bridge interfaces (lo, lo0, docker0). This is normal when Capture runs inside a container that can only see its own network namespace — run Capture directly on the host to see real network activity.", + "loopbackOnlyNotice": "Capture is only seeing container network interfaces, and none of them are carrying real traffic. This is normal when Capture runs inside a container that can only observe its own network namespace — interfaces with host-like names (eth0, ens3) are the container's virtual interfaces, not the host's. Run Capture directly on the host to see real network activity.", "temp": "CPU temperature" } }, From 1ca38a31c93ce8586bbe79a4f55ba5f188af36f2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 12:24:44 -0700 Subject: [PATCH 009/137] add new docker payload types, promote docker type monitor to first class monitor --- server/src/domain/monitors/monitor.model.ts | 3 + .../monitors/monitor.repository.mongo.ts | 1 + server/src/domain/monitors/monitor.type.ts | 1 + server/src/service/network/DockerProvider.ts | 250 ++++++++++-------- server/src/types/network.ts | 34 ++- 5 files changed, 177 insertions(+), 112 deletions(-) diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index 15b277efb..ba803dc67 100644 --- a/server/src/domain/monitors/monitor.model.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -233,6 +233,9 @@ const MonitorSchema = new Schema( secret: { type: String, }, + sshPrivateKey: { + type: String, + }, cpuAlertThreshold: { type: Number, default: 100, diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index 8f773f983..9617ba3f6 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -436,6 +436,7 @@ 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, diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index 59d91ced9..1767ba17f 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -99,6 +99,7 @@ export interface Monitor { tags: string[]; customUpCodes: HttpStatusCode[]; secret?: string; + sshPrivateKey?: string; cpuAlertThreshold: number; cpuAlertCounter: number; memoryAlertThreshold: number; diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 9f2a99de7..543f73011 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -1,5 +1,14 @@ import { IStatusProvider } from "@/service/network/IStatusProvider.js"; -import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; +import { + DockerContainerInfo, + DockerContainerState, + DockerContainerStates, + DockerContainerSummary, + DockerHealthStatus, + DockerHealthStatuses, + DockerStatusPayload, + MonitorStatusResponse, +} from "@/types/network.js"; import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { ILogger } from "@/utils/logger.js"; import { AppError } from "@/utils/AppError.js"; @@ -9,6 +18,7 @@ import { NETWORK_ERROR } from "@/types/network.js"; type DockerodeType = typeof Dockerode; const SERVICE_NAME = "DockerProvider"; +const STATS_CONCURRENCY = 5; export interface DockerError extends Error { statusCode?: number; @@ -18,13 +28,11 @@ export interface DockerError extends Error { export class DockerProvider implements IStatusProvider { readonly type = "docker"; - private docker: Dockerode; + constructor( private logger: ILogger, private DockerLib: DockerodeType - ) { - this.docker = new this.DockerLib(); - } + ) {} supports(type: MonitorType): boolean { return type === "docker"; @@ -34,141 +42,161 @@ export class DockerProvider implements IStatusProvider { return error instanceof Error && ("statusCode" in error || "reason" in error || "json" in error); } - async handle(monitor: Monitor): Promise> { - const { url: containerInput } = monitor; + private toDockerOptions = (monitor: Monitor): Dockerode.DockerOptions => { + const url = monitor.url?.trim(); + if (!url) throw new AppError({ message: "Docker host URL is required", status: 422, service: SERVICE_NAME, method: "toDockerOptions" }); + if (url.startsWith("unix://")) { + const socketPath = url.slice("unix://".length); + if (!socketPath.startsWith("/")) throw new Error(`Invalid Docker host URL: ${url}`); + 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", 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", + }); + return { + protocol: "ssh", + host, + port: port ? Number(port) : 22, + username, + sshOptions: { privateKey: monitor.sshPrivateKey }, + }; + }; + + private async mapWithConcurrency(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const results: R[] = []; + for (let i = 0; i < items.length; i += limit) { + results.push(...(await Promise.all(items.slice(i, i + limit).map(fn)))); + } + return results; + } + + private toContainerState(state: string): DockerContainerState { + return (DockerContainerStates as readonly string[]).includes(state) ? (state as DockerContainerState) : "created"; + } + + private toHealthStatus(status: string | undefined): DockerHealthStatus { + return status && (DockerHealthStatuses as readonly string[]).includes(status) ? (status as DockerHealthStatus) : "none"; + } + + private computeCpuPct(stats: Dockerode.ContainerStats): number { + const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; + const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats.system_cpu_usage ?? 0); + const onlineCpus = stats.cpu_stats.online_cpus ?? stats.cpu_stats.cpu_usage.percpu_usage?.length ?? 1; + if (!(systemDelta > 0) || cpuDelta < 0) return 0; + return (cpuDelta / systemDelta) * onlineCpus * 100; + } + + private computeMemory(stats: Dockerode.ContainerStats): Pick { + const raw = stats.memory_stats.usage ?? 0; + const pageCache = stats.memory_stats.stats?.inactive_file ?? stats.memory_stats.stats?.cache ?? 0; + const memoryUsedBytes = Math.max(0, raw - pageCache); + const memoryLimitBytes = stats.memory_stats.limit ?? 0; + return { + memoryUsedBytes, + memoryLimitBytes, + memoryPct: memoryLimitBytes > 0 ? (memoryUsedBytes / memoryLimitBytes) * 100 : 0, + }; + } + + 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), + image: summary.Image, + state: this.toContainerState(summary.State), + status: summary.Status, + health: "none", + }; + + const container = docker.getContainer(summary.Id); + const [inspectResult, statsResult] = await Promise.allSettled([ + container.inspect(), + summary.State === "running" ? container.stats({ stream: false }) : Promise.resolve(null), + ]); + + if (inspectResult.status === "fulfilled") { + info.restartCount = inspectResult.value.RestartCount; + info.startedAt = inspectResult.value.State?.StartedAt; + info.health = this.toHealthStatus(inspectResult.value.State?.Health?.Status); + } else { + this.logger.warn({ + message: `Failed to inspect container ${info.name}`, + service: SERVICE_NAME, + method: "toContainerInfo", + details: { containerId: summary.Id }, + }); + } + + if (statsResult.status === "fulfilled" && statsResult.value) { + info.cpuPct = this.computeCpuPct(statsResult.value); + Object.assign(info, this.computeMemory(statsResult.value)); + } + + return info; + } + + handle = async (monitor: Monitor): Promise> => { try { - if (!containerInput) { - throw new Error("Container name or ID is required for Docker monitor"); - } - - const containers = await this.docker.listContainers({ all: true }); - const normalizedInput = containerInput.replace(/^\/+/, "").toLowerCase(); - - // Priority-based matching to avoid ambiguity: - // 1. Exact full ID match (64-char) - const exactIdMatch = containers.find((c) => c.Id.toLowerCase() === normalizedInput); - - // 2. Exact container name match (case-insensitive) - const exactNameMatch = containers.find((c) => - c.Names.some((name: string) => { - const cleanName = name.replace(/^\/+/, "").toLowerCase(); - return cleanName === normalizedInput; - }) - ); - - // 3. Partial ID match (fallback for backwards compatibility) - const partialIdMatch = containers.find((c) => c.Id.toLowerCase().startsWith(normalizedInput)); - - // Select container based on priority - const targetContainer = exactIdMatch || exactNameMatch || partialIdMatch; - - // Handle no match - if (!targetContainer) { - this.logger.warn({ - message: `No container found for "${monitor.url}".`, - service: SERVICE_NAME, - method: "handle", - details: { url: monitor.url }, - }); - - return { - monitorId: monitor.id, - teamId: monitor.teamId, - type: monitor.type, - status: false, - code: 404, - message: "Docker container not found", - responseTime: 0, - payload: null, - }; - } - - // 5. Handle Ambiguity Check - const matchTypes: string[] = []; - if (exactIdMatch) matchTypes.push("exact ID"); - if (exactNameMatch) matchTypes.push("exact name"); - if (partialIdMatch && !exactIdMatch) matchTypes.push("partial ID"); - - if (matchTypes.length > 1) { - const matchUsed = exactIdMatch ? "exact ID" : "exact name"; - const message = `Ambiguous container match for "${containerInput}". Matched by: ${matchTypes.join(", ")}. Using ${matchUsed} match.`; - - this.logger.warn({ - message, - service: SERVICE_NAME, - method: "handle", - details: { url: containerInput }, - }); - - return { - monitorId: monitor.id, - teamId: monitor.teamId, - type: monitor.type, - status: false, - code: NETWORK_ERROR, - message, - responseTime: 0, - payload: null, - }; - } - - // 6. Inspect Container Status - const container = this.docker.getContainer(targetContainer.Id); - const { response, responseTime, error } = await timeRequest(() => container.inspect()); - + const docker = new this.DockerLib(this.toDockerOptions(monitor)); + // Host reachability is the monitor's status; ping latency is the responseTime + const { responseTime, error } = await timeRequest(() => docker.ping()); if (error) { - let message = "Failed to fetch Docker container information"; + let message = "Docker host is unreachable"; let code = NETWORK_ERROR; - if (this.isDockerError(error)) { code = error.statusCode ?? NETWORK_ERROR; message = error.json?.message ?? error.reason ?? error.message; } else if (error instanceof Error) { message = error.message; } - return { monitorId: monitor.id, teamId: monitor.teamId, type: monitor.type, status: false, - code: code, - message: message, - responseTime, - payload: null, - }; - } - - if (!response) { - return { - monitorId: monitor.id, - teamId: monitor.teamId, - type: monitor.type, - status: false, - code: NETWORK_ERROR, - message: "No response from Docker container inspect", + code, + message, responseTime, payload: null, }; } + const summaries = await docker.listContainers({ all: true }); + const containers = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerInfo(docker, s)); + const summary: DockerContainerSummary = { + total: containers.length, + running: containers.filter((c) => c.state === "running").length, + stopped: containers.filter((c) => c.state === "exited" || c.state === "dead").length, + unhealthy: containers.filter((c) => c.health === "unhealthy").length, + }; return { monitorId: monitor.id, teamId: monitor.teamId, type: monitor.type, - status: response.State?.Status === "running", + status: true, code: 200, - message: "Docker container status fetched successfully", + message: "Docker host is reachable", responseTime, - payload: response as unknown as DockerStatusPayload, + payload: { containers, summary }, }; - } catch (err: unknown) { + } catch (error: unknown) { + if (error instanceof AppError) throw error; throw new AppError({ - message: err instanceof Error ? err.message : "Error performing Docker request", + message: error instanceof Error ? error.message : "Error performing Docker request", service: SERVICE_NAME, method: "handle", - details: { url: containerInput }, + details: { + url: monitor.url, + }, }); } - } + }; } diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 0c8dd7cae..f92268f42 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -96,7 +96,39 @@ export interface HardwareStatusPayload { [key: string]: unknown; } -export type DockerStatusPayload = Record; +// Docker host monitoring +export const DockerContainerStates = ["created", "running", "paused", "restarting", "removing", "exited", "dead"]; +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; + memoryUsedBytes?: number; + memoryLimitBytes?: number; + memoryPct?: number; + restartCount?: number; + startedAt?: string; // ISO date +} + +export interface DockerStatusPayload { + containers: DockerContainerInfo[]; + summary: DockerContainerSummary; +} export interface PortStatusPayload { success: boolean; From 8f6c909acdb0abff08ff0eeac9d893a5212b574f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 12:28:26 -0700 Subject: [PATCH 010/137] add missing const, add key check --- server/src/service/network/DockerProvider.ts | 15 ++++++++++++--- server/src/types/network.ts | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 543f73011..a24ad2c18 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -47,19 +47,28 @@ export class DockerProvider implements IStatusProvider { if (!url) throw new AppError({ message: "Docker host URL is required", status: 422, service: SERVICE_NAME, method: "toDockerOptions" }); if (url.startsWith("unix://")) { const socketPath = url.slice("unix://".length); - if (!socketPath.startsWith("/")) throw new Error(`Invalid Docker host URL: ${url}`); + if (!socketPath.startsWith("/")) + throw new AppError({ message: `Invalid Docker host URL: ${url}`, status: 422, service: SERVICE_NAME, method: "toDockerOptions" }); 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", status: 422, service: SERVICE_NAME, method: "toDockerOptions" }); + 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", + 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", diff --git a/server/src/types/network.ts b/server/src/types/network.ts index f92268f42..62f359b46 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -97,7 +97,7 @@ export interface HardwareStatusPayload { } // Docker host monitoring -export const DockerContainerStates = ["created", "running", "paused", "restarting", "removing", "exited", "dead"]; +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; From 9c8bf4bb0583d52a05cd0617b990cd9a958f878b Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 12:39:56 -0700 Subject: [PATCH 011/137] update tests --- .../providers/network/dockerProvider.test.ts | 556 ++++++++++-------- 1 file changed, 303 insertions(+), 253 deletions(-) diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts index 6d16b3e5c..4aa1431dc 100644 --- a/server/test/unit/providers/network/dockerProvider.test.ts +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -3,51 +3,75 @@ import { DockerProvider } from "../../../../src/service/network/DockerProvider.t import { testStatusProviderContract } from "../../../helpers/statusProviderContract.ts"; 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"; -// ── Helpers ────────────────────────────────────────────────────────────────── +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const PRIVATE_KEY = "-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----"; const makeMonitor = (overrides?: Partial): Monitor => ({ id: "mon-1", teamId: "team-1", type: "docker", - url: "my-container", + url: "unix:///var/run/docker.sock", ...overrides, }) as Monitor; const makeContainer = (overrides?: Record) => ({ Id: "abc123def456abc123def456abc123def456abc123def456abc123def456abcd", Names: ["/my-container"], + Image: "nginx:latest", State: "running", + Status: "Up 3 hours (healthy)", ...overrides, }); -const createMockDocker = (containers: any[] = [makeContainer()], inspectResult?: any) => { - const inspect = jest.fn().mockResolvedValue(inspectResult ?? { State: { Status: "running" } }); - const getContainer = jest.fn().mockReturnValue({ inspect }); +const makeInspect = (overrides?: Record) => ({ + RestartCount: 2, + State: { StartedAt: "2026-08-28T10:00:00.000Z", Health: { Status: "healthy" } }, + ...overrides, +}); +// cpuDelta 200M over systemDelta 1000M on 4 cpus → 80%; memory 300MiB raw - 100MiB page cache = 200MiB of 1GiB → 19.53125% +const makeStats = (overrides?: Record) => ({ + cpu_stats: { + cpu_usage: { total_usage: 400_000_000 }, + system_cpu_usage: 2_000_000_000, + online_cpus: 4, + }, + precpu_stats: { + cpu_usage: { total_usage: 200_000_000 }, + system_cpu_usage: 1_000_000_000, + }, + memory_stats: { + usage: 300 * 1024 * 1024, + limit: 1024 * 1024 * 1024, + stats: { inactive_file: 100 * 1024 * 1024 }, + }, + ...overrides, +}); + +const setup = (overrides: Partial<{ ping: any; listContainers: any; getContainer: any; inspect: any; stats: 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 instance = { - listContainers: jest.fn().mockResolvedValue(containers), + ping: overrides.ping ?? jest.fn().mockResolvedValue("OK"), + listContainers: overrides.listContainers ?? jest.fn().mockResolvedValue([makeContainer()]), getContainer, }; - const DockerLib = jest.fn().mockReturnValue(instance) as any; - - return { DockerLib, instance, inspect, getContainer }; -}; - -const createProvider = (opts?: { containers?: any[]; inspectResult?: any }) => { const logger = createMockLogger(); - const { DockerLib, instance, inspect, getContainer } = createMockDocker(opts?.containers, opts?.inspectResult); const provider = new DockerProvider(logger as any, DockerLib); - return { provider, logger, docker: instance, inspect, getContainer }; + return { provider, logger, DockerLib, ping: instance.ping, listContainers: instance.listContainers, getContainer, inspect, stats }; }; // ── Contract ───────────────────────────────────────────────────────────────── testStatusProviderContract("DockerProvider", { - create: () => createProvider().provider, + create: () => setup().provider, supportedType: "docker", unsupportedType: "http", makeMonitor: () => makeMonitor(), @@ -56,286 +80,312 @@ testStatusProviderContract("DockerProvider", { // ── Tests ──────────────────────────────────────────────────────────────────── describe("DockerProvider", () => { - // ── Container matching ─────────────────────────────────────────────── + // ── Host url parsing ───────────────────────────────────────────────── - describe("container matching", () => { - it("matches by exact container name", async () => { - const { provider } = createProvider(); + describe("host url parsing", () => { + it("parses unix:// urls into socketPath", async () => { + const { provider, DockerLib } = setup(); - const result = await provider.handle(makeMonitor({ url: "my-container" })); + await provider.handle(makeMonitor({ url: "unix:///run/docker.sock" })); + + expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/run/docker.sock" }); + }); + + it("parses bare absolute paths into socketPath", async () => { + const { provider, DockerLib } = setup(); + + await provider.handle(makeMonitor({ url: "/var/run/docker.sock" })); + + expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/var/run/docker.sock" }); + }); + + it("trims surrounding whitespace", async () => { + const { provider, DockerLib } = setup(); + + await provider.handle(makeMonitor({ url: " /var/run/docker.sock " })); + + 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 ─────────────────────────────────────── + + describe("invalid host urls", () => { + const invalidUrls: [label: string, url: string, message: string][] = [ + ["empty url", "", "Docker host URL is required"], + ["unix:// with an empty path", "unix://", "Invalid Docker host URL"], + ["unix:// with a relative path", "unix://run/docker.sock", "Invalid Docker host URL"], + ["garbage", "not-a-url", "Invalid Docker host URL"], + ["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"], + ]; + + it.each(invalidUrls)("throws AppError for %s and never constructs a client", async (_label, url, message) => { + const { provider, DockerLib } = setup(); + + await expect(provider.handle(makeMonitor({ url }))).rejects.toThrow(message); + 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(); + + await expect(provider.handle(makeMonitor({ url: "not-a-url" }))).rejects.toMatchObject({ + service: "DockerProvider", + method: "toDockerOptions", + status: 422, + }); + await expect(provider.handle(makeMonitor({ url: "not-a-url" }))).rejects.toBeInstanceOf(AppError); + }); + }); + + // ── Host reachability ──────────────────────────────────────────────── + + describe("host reachability", () => { + it("returns a down check with NETWORK_ERROR when ping rejects with a plain Error", async () => { + const { provider, listContainers } = setup({ ping: jest.fn().mockRejectedValue(new Error("ECONNREFUSED")) }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.code).toBe(NETWORK_ERROR); + expect(result.message).toBe("ECONNREFUSED"); + expect(result.payload).toBeNull(); + expect(listContainers).not.toHaveBeenCalled(); + }); + + it("uses the DockerError statusCode and json message when ping rejects with one", async () => { + const dockerErr = Object.assign(new Error("base"), { statusCode: 500, json: { message: "engine exploded" } }); + const { provider } = setup({ ping: jest.fn().mockRejectedValue(dockerErr) }); + + const result = await provider.handle(makeMonitor()); + + expect(result.code).toBe(500); + expect(result.message).toBe("engine exploded"); + }); + + it("falls back to the DockerError reason when json has no message", async () => { + const dockerErr = Object.assign(new Error("base"), { statusCode: 500, reason: "server error", json: {} }); + const { provider } = setup({ ping: jest.fn().mockRejectedValue(dockerErr) }); + + const result = await provider.handle(makeMonitor()); + + expect(result.message).toBe("server error"); + }); + + it("uses the default message when ping rejects with a non-Error", async () => { + const { provider } = setup({ ping: jest.fn().mockRejectedValue("nope") }); + + const result = await provider.handle(makeMonitor()); + + expect(result.status).toBe(false); + expect(result.message).toBe("Docker host is unreachable"); + }); + }); + + // ── Happy path ─────────────────────────────────────────────────────── + + describe("container payload", () => { + it("returns an up check with enriched containers and summary counts", async () => { + const { provider } = setup(); + + const result = await provider.handle(makeMonitor()); expect(result.status).toBe(true); expect(result.code).toBe(200); - }); - - it("matches by exact full ID (64 chars)", async () => { - const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd"; - const { provider } = createProvider(); - - const result = await provider.handle(makeMonitor({ url: fullId })); - - expect(result.status).toBe(true); - }); - - it("matches by partial ID prefix", async () => { - const { provider } = createProvider(); - - const result = await provider.handle(makeMonitor({ url: "abc123" })); - - expect(result.status).toBe(true); - }); - - it("strips leading slashes from input", async () => { - const { provider } = createProvider(); - - const result = await provider.handle(makeMonitor({ url: "///my-container" })); - - expect(result.status).toBe(true); - }); - - it("matches case-insensitively", async () => { - const { provider } = createProvider(); - - const result = await provider.handle(makeMonitor({ url: "MY-CONTAINER" })); - - expect(result.status).toBe(true); - }); - - it("returns 404 when no container matches", async () => { - const { provider, logger } = createProvider(); - - const result = await provider.handle(makeMonitor({ url: "nonexistent" })); - - expect(result.status).toBe(false); - expect(result.code).toBe(404); - expect(result.message).toBe("Docker container not found"); - expect(logger.warn).toHaveBeenCalledWith( + expect(result.message).toBe("Docker host is reachable"); + expect(result.payload?.summary).toEqual({ total: 1, running: 1, stopped: 0, unhealthy: 0 }); + expect(result.payload?.containers[0]).toEqual( expect.objectContaining({ - message: expect.stringContaining("No container found"), - }) - ); - }); - }); - - // ── Ambiguity detection ────────────────────────────────────────────── - - describe("ambiguity detection", () => { - it("returns error when input matches both exact name and partial ID", async () => { - // Container whose name matches the input AND whose ID starts with the input - const container = makeContainer({ - Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd", - Names: ["/my-container"], - }); - const { provider, logger } = createProvider({ containers: [container] }); - - const result = await provider.handle(makeMonitor({ url: "my-container" })); - - expect(result.status).toBe(false); - expect(result.code).toBe(NETWORK_ERROR); - expect(result.message).toContain("Ambiguous"); - expect(logger.warn).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining("Ambiguous"), + id: makeContainer().Id, + name: "my-container", + image: "nginx:latest", + state: "running", + status: "Up 3 hours (healthy)", + health: "healthy", + restartCount: 2, + startedAt: "2026-08-28T10:00:00.000Z", }) ); }); - it("reports 'exact ID' in ambiguity message when exact ID and name both match", async () => { - const fullId = "abc123def456abc123def456abc123def456abc123def456abc123def456abcd"; - const container = makeContainer({ - Id: fullId, - Names: ["/" + fullId], - }); - const { provider } = createProvider({ containers: [container] }); + it("counts running, stopped, and unhealthy states in the summary", async () => { + const containers = [ + makeContainer({ Id: "a".repeat(64) }), + makeContainer({ Id: "b".repeat(64), State: "exited" }), + makeContainer({ Id: "c".repeat(64), State: "dead" }), + makeContainer({ Id: "d".repeat(64), State: "paused" }), + ]; + const inspect = jest.fn().mockResolvedValue(makeInspect({ State: { Health: { Status: "unhealthy" } } })); + const { provider } = setup({ listContainers: jest.fn().mockResolvedValue(containers), inspect }); - const result = await provider.handle(makeMonitor({ url: fullId })); + const result = await provider.handle(makeMonitor()); - expect(result.message).toContain("Using exact ID"); + expect(result.payload?.summary).toEqual({ total: 4, running: 1, stopped: 2, unhealthy: 4 }); }); - it("reports 'exact name' in ambiguity message when name and partial ID match", async () => { - const container = makeContainer({ - Id: "my-containerabc123def456abc123def456abc123def456abc123def456abcd", - Names: ["/my-container"], - }); - const { provider } = createProvider({ containers: [container] }); + it("falls back to a truncated id when a container has no name", async () => { + const container = makeContainer({ Names: [] }); + const { provider } = setup({ listContainers: jest.fn().mockResolvedValue([container]) }); - const result = await provider.handle(makeMonitor({ url: "my-container" })); + const result = await provider.handle(makeMonitor()); - expect(result.message).toContain("Using exact name"); + expect(result.payload?.containers[0]?.name).toBe(container.Id.slice(0, 12)); + }); + + it("clamps unknown container states and health statuses", async () => { + const container = makeContainer({ State: "warp-speed" }); + const inspect = jest.fn().mockResolvedValue(makeInspect({ State: { Health: { Status: "confused" } } })); + const { provider } = setup({ listContainers: jest.fn().mockResolvedValue([container]), inspect }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.containers[0]?.state).toBe("created"); + expect(result.payload?.containers[0]?.health).toBe("none"); }); }); - // ── Container inspection ───────────────────────────────────────────── + // ── Enrichment fault isolation ─────────────────────────────────────── - describe("container inspection", () => { - it("returns running status for running container", async () => { - const { provider } = createProvider({ inspectResult: { State: { Status: "running" } } }); + describe("enrichment fault isolation", () => { + it("keeps the container and the up status when inspect rejects", async () => { + const inspect = jest.fn().mockRejectedValue(new Error("inspect failed")); + const { provider, logger } = setup({ inspect }); const result = await provider.handle(makeMonitor()); expect(result.status).toBe(true); - expect(result.message).toBe("Docker container status fetched successfully"); + expect(result.payload?.containers).toHaveLength(1); + expect(result.payload?.containers[0]).toEqual(expect.objectContaining({ health: "none" })); + expect(result.payload?.containers[0]?.restartCount).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Failed to inspect") })); }); - it("returns not-running status for stopped container", async () => { - const { provider } = createProvider({ inspectResult: { State: { Status: "exited" } } }); + it("keeps the container without metrics when stats rejects", async () => { + const stats = jest.fn().mockRejectedValue(new Error("stats failed")); + const { provider } = setup({ stats }); const result = await provider.handle(makeMonitor()); - expect(result.status).toBe(false); + expect(result.status).toBe(true); + expect(result.payload?.containers[0]?.cpuPct).toBeUndefined(); + expect(result.payload?.containers[0]?.memoryUsedBytes).toBeUndefined(); }); - it("returns NETWORK_ERROR when inspect response is null", async () => { - const logger = createMockLogger(); - const inspect = jest.fn().mockResolvedValue(null); - const getContainer = jest.fn().mockReturnValue({ inspect }); - const instance = { listContainers: jest.fn().mockResolvedValue([makeContainer()]), getContainer }; - const DockerLib = jest.fn().mockReturnValue(instance) as any; - const provider = new DockerProvider(logger as any, DockerLib); + it("only requests stats for running containers", async () => { + const containers = [makeContainer({ Id: "a".repeat(64) }), makeContainer({ Id: "b".repeat(64), State: "exited" })]; + const { provider, stats } = setup({ listContainers: jest.fn().mockResolvedValue(containers) }); - const result = await provider.handle(makeMonitor()); + await provider.handle(makeMonitor()); - expect(result.status).toBe(false); - expect(result.code).toBe(NETWORK_ERROR); - expect(result.message).toBe("No response from Docker container inspect"); - }); - - it("returns false status when inspect response has no State", async () => { - const { provider } = createProvider({ inspectResult: {} }); - - const result = await provider.handle(makeMonitor()); - - expect(result.status).toBe(false); - }); - - it("returns false status when inspect response has State but no Status", async () => { - const { provider } = createProvider({ inspectResult: { State: {} } }); - - const result = await provider.handle(makeMonitor()); - - expect(result.status).toBe(false); - }); - - it("handles inspect error with Docker-specific error", async () => { - const { provider } = createProvider(); - // Make inspect throw a Docker error - const dockerErr = Object.assign(new Error("container not found"), { - statusCode: 404, - reason: "no such container", - json: { message: "Container not found" }, - }); - provider["docker"] = { - listContainers: jest.fn().mockResolvedValue([makeContainer()]), - getContainer: jest.fn().mockReturnValue({ - inspect: jest.fn().mockRejectedValue(dockerErr), - }), - } as any; - - // Need to re-assign; simpler to use the createProvider mock - const { provider: p2 } = createProvider(); - // Override getContainer to return failing inspect - p2["docker"].getContainer = jest.fn().mockReturnValue({ - inspect: jest.fn().mockRejectedValue(dockerErr), - }); - - const result = await p2.handle(makeMonitor()); - - expect(result.status).toBe(false); - expect(result.code).toBe(404); - expect(result.message).toBe("Container not found"); - }); - - it("handles inspect error with Docker error using reason fallback", async () => { - const { provider } = createProvider(); - const dockerErr = Object.assign(new Error("error"), { - statusCode: 500, - reason: "internal error", - json: {}, - }); - provider["docker"].getContainer = jest.fn().mockReturnValue({ - inspect: jest.fn().mockRejectedValue(dockerErr), - }); - - const result = await provider.handle(makeMonitor()); - - expect(result.message).toBe("internal error"); - }); - - it("handles inspect error with Docker error using Error message fallback", async () => { - const { provider } = createProvider(); - const dockerErr = Object.assign(new Error("base error"), { - statusCode: 503, - reason: undefined, - json: undefined, - }); - provider["docker"].getContainer = jest.fn().mockReturnValue({ - inspect: jest.fn().mockRejectedValue(dockerErr), - }); - - const result = await provider.handle(makeMonitor()); - - expect(result.message).toBe("base error"); - }); - - it("handles inspect error with NETWORK_ERROR when no statusCode", async () => { - const { provider } = createProvider(); - const dockerErr = Object.assign(new Error("unknown"), { - statusCode: undefined, - }); - provider["docker"].getContainer = jest.fn().mockReturnValue({ - inspect: jest.fn().mockRejectedValue(dockerErr), - }); - - const result = await provider.handle(makeMonitor()); - - expect(result.code).toBe(NETWORK_ERROR); - }); - - it("handles inspect error with standard Error (not Docker error)", async () => { - const { provider } = createProvider(); - provider["docker"].getContainer = jest.fn().mockReturnValue({ - inspect: jest.fn().mockRejectedValue(new Error("ECONNREFUSED")), - }); - - const result = await provider.handle(makeMonitor()); - - expect(result.status).toBe(false); - expect(result.message).toBe("ECONNREFUSED"); - }); - - it("handles inspect error with non-Error non-Docker thrown value", async () => { - const { provider } = createProvider(); - provider["docker"].getContainer = jest.fn().mockReturnValue({ - inspect: jest.fn().mockRejectedValue("string error"), - }); - - const result = await provider.handle(makeMonitor()); - - expect(result.status).toBe(false); - expect(result.message).toBe("Failed to fetch Docker container information"); + expect(stats).toHaveBeenCalledTimes(1); }); }); - // ── Outer catch ────────────────────────────────────────────────────── + // ── Metric math ────────────────────────────────────────────────────── + + describe("metric math", () => { + it("computes cpu and memory percentages from cgroup v2 stats", async () => { + const { provider } = setup(); + + const result = await provider.handle(makeMonitor()); + const container = result.payload?.containers[0]; + + expect(container?.cpuPct).toBeCloseTo(80); + expect(container?.memoryUsedBytes).toBe(200 * 1024 * 1024); + expect(container?.memoryLimitBytes).toBe(1024 * 1024 * 1024); + expect(container?.memoryPct).toBeCloseTo(19.53125); + }); + + it("falls back to the cgroup v1 cache field for page cache", async () => { + const stats = jest + .fn() + .mockResolvedValue(makeStats({ memory_stats: { usage: 300 * 1024 * 1024, limit: 1024 * 1024 * 1024, stats: { cache: 50 * 1024 * 1024 } } })); + const { provider } = setup({ stats }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.containers[0]?.memoryUsedBytes).toBe(250 * 1024 * 1024); + }); + + it("derives cpu count from percpu_usage when online_cpus is absent", async () => { + const stats = jest.fn().mockResolvedValue( + makeStats({ + cpu_stats: { cpu_usage: { total_usage: 400_000_000, percpu_usage: [1, 2] }, system_cpu_usage: 2_000_000_000 }, + }) + ); + const { provider } = setup({ stats }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.containers[0]?.cpuPct).toBeCloseTo(40); + }); + + it("returns 0 cpu when the system delta is not positive", async () => { + const stats = jest + .fn() + .mockResolvedValue(makeStats({ precpu_stats: { cpu_usage: { total_usage: 200_000_000 }, system_cpu_usage: 2_000_000_000 } })); + const { provider } = setup({ stats }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.containers[0]?.cpuPct).toBe(0); + }); + + it("returns 0 memory percent when there is no limit", async () => { + const stats = jest.fn().mockResolvedValue(makeStats({ memory_stats: { usage: 300 * 1024 * 1024, limit: 0, stats: {} } })); + const { provider } = setup({ stats }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.containers[0]?.memoryPct).toBe(0); + }); + }); + + // ── Outer error handling ───────────────────────────────────────────── describe("outer error handling", () => { - it("throws AppError when containerInput is missing", async () => { - const { provider } = createProvider(); - - await expect(provider.handle(makeMonitor({ url: "" }))).rejects.toThrow("Container name or ID is required for Docker monitor"); - }); - - it("throws AppError when listContainers throws", async () => { - const { provider } = createProvider(); - provider["docker"].listContainers = jest.fn().mockRejectedValue(new Error("Docker daemon unavailable")); + it("throws AppError when listContainers rejects after a successful ping", async () => { + const { provider } = setup({ listContainers: jest.fn().mockRejectedValue(new Error("Docker daemon unavailable")) }); await expect(provider.handle(makeMonitor())).rejects.toThrow("Docker daemon unavailable"); }); - it("throws AppError with default message for non-Error thrown values", async () => { - const { provider } = createProvider(); - provider["docker"].listContainers = jest.fn().mockRejectedValue(null); + it("throws AppError with the default message for non-Error thrown values", async () => { + const { provider } = setup({ listContainers: jest.fn().mockRejectedValue(null) }); await expect(provider.handle(makeMonitor())).rejects.toThrow("Error performing Docker request"); }); From 686ded2137154b07c932de980557892ee11c5e57 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 13:02:38 -0700 Subject: [PATCH 012/137] update check and monitor domains --- .../api/validation/statusPageValidation.ts | 1 + server/src/domain/checks/check.model.ts | 39 ++++++++++++++++++- .../domain/checks/check.repository.mongo.ts | 2 + server/src/domain/checks/check.service.ts | 8 +++- server/src/domain/checks/check.snapshot.ts | 6 +++ server/src/domain/checks/check.type.ts | 31 +++++++++++++++ server/src/domain/monitors/monitor.model.ts | 2 + 7 files changed, 87 insertions(+), 2 deletions(-) diff --git a/server/src/api/validation/statusPageValidation.ts b/server/src/api/validation/statusPageValidation.ts index ad2277797..e92f28a57 100644 --- a/server/src/api/validation/statusPageValidation.ts +++ b/server/src/api/validation/statusPageValidation.ts @@ -112,6 +112,7 @@ export const checkSnapshotResponseSchema = z bestPractices: z.number().optional(), seo: z.number().optional(), performance: z.number().optional(), + containerSummary: z.object({ total: z.number(), running: z.number(), stopped: z.number(), unhealthy: z.number() }).optional(), }) .passthrough(); diff --git a/server/src/domain/checks/check.model.ts b/server/src/domain/checks/check.model.ts index 9f3c2fbc1..a101375f4 100644 --- a/server/src/domain/checks/check.model.ts +++ b/server/src/domain/checks/check.model.ts @@ -14,6 +14,7 @@ import type { GotTimings, ILighthouseAudit, } from "@/domain/checks/check.type.js"; +import { DockerContainerInfo, DockerContainerStates, DockerContainerSummary, DockerHealthStatuses } from "@/types/network.js"; type CheckMetadataDocument = Omit & { monitorId: Types.ObjectId; @@ -172,6 +173,34 @@ const auditsSchema = new Schema( { _id: false } ); +const dockerContainerSchema = new Schema( + { + id: { type: String, required: true }, + name: { type: String, default: "" }, + image: { type: String, default: "" }, + state: { type: String, enum: DockerContainerStates, default: "created" }, + status: { type: String, default: "" }, + health: { type: String, enum: DockerHealthStatuses, default: "none" }, + cpuPct: { type: Number }, + memoryUsedBytes: { type: Number }, + memoryLimitBytes: { type: Number }, + memoryPct: { type: Number }, + restartCount: { type: Number }, + startedAt: { type: String }, + }, + { _id: false } +); + +const containerSummarySchema = new Schema( + { + total: { type: Number, default: 0 }, + running: { type: Number, default: 0 }, + stopped: { type: Number, default: 0 }, + unhealthy: { type: Number, default: 0 }, + }, + { _id: false } +); + const metadataSchema = new Schema( { monitorId: { @@ -268,6 +297,14 @@ const CheckSchema = new Schema( type: auditsSchema, default: undefined, }, + containers: { + type: [dockerContainerSchema], + default: undefined, + }, + containerSummary: { + type: containerSummarySchema, + default: undefined, + }, }, { timestamps: true, @@ -291,5 +328,5 @@ CheckSchema.index({ "metadata.teamId": 1, status: 1, createdAt: -1 }); const CheckModel = model("Check", CheckSchema); export type { CheckDocument, CheckMetadataDocument }; -export { CheckModel }; +export { CheckModel, containerSummarySchema }; export default CheckModel; diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index e64113b6c..9b46f2dbd 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -167,6 +167,8 @@ class MongoChecksRepository implements IChecksRepository { seo: doc.seo, performance: doc.performance, audits: mapAudits(doc.audits), + containers: doc.containers, + containerSummary: doc.containerSummary, createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; diff --git a/server/src/domain/checks/check.service.ts b/server/src/domain/checks/check.service.ts index 40dd88f80..5eb071bab 100644 --- a/server/src/domain/checks/check.service.ts +++ b/server/src/domain/checks/check.service.ts @@ -2,7 +2,7 @@ import { Types } from "mongoose"; import { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js"; import type { Check, CheckErrorInfo, ChecksQueryResult, ChecksSummary, ILighthouseAudit } from "@/domain/checks/check.type.js"; -import type { MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js"; +import type { DockerStatusPayload, MonitorPayloadMap, MonitorStatusResponse } from "@/types/network.js"; import type { HardwareStatusPayload, PageSpeedStatusPayload } from "@/types/network.js"; import { AppError } from "@/utils/AppError.js"; import { ILogger } from "@/utils/logger.js"; @@ -131,6 +131,12 @@ export class CheckService implements ICheckService { check.capture = hardwarePayload?.capture; check.net = net; } + + if (type === "docker") { + const dockerPayload = payload as DockerStatusPayload | undefined; + check.containers = dockerPayload?.containers; + check.containerSummary = dockerPayload?.summary; + } return check; }; diff --git a/server/src/domain/checks/check.snapshot.ts b/server/src/domain/checks/check.snapshot.ts index 3ee208d04..7348a2d55 100644 --- a/server/src/domain/checks/check.snapshot.ts +++ b/server/src/domain/checks/check.snapshot.ts @@ -11,6 +11,7 @@ import type { SnapshotHostInfo, SnapshotMemoryInfo, } from "@/domain/checks/check.type.js"; +import { DockerContainerSummary } from "@/types/network.js"; import { toDateString } from "@/utils/mongoMappers.js"; export type CheckSnapshotSource = Pick< @@ -23,6 +24,7 @@ export type CheckSnapshotSource = Pick< disk?: CheckDiskInfo[]; host?: CheckHostInfo; audits?: CheckAudits; + containerSummary?: DockerContainerSummary; }; const mapCpu = (cpu?: CheckCpuInfo): SnapshotCpuInfo | undefined => @@ -66,6 +68,9 @@ const mapAudits = (audits?: CheckAudits): CheckAudits | undefined => tbt: audits.tbt, }; +const mapContainerSummary = (summary?: DockerContainerSummary): DockerContainerSummary | undefined => + summary && { total: summary.total, running: summary.running, stopped: summary.stopped, unhealthy: summary.unhealthy }; + export const toCheckSnapshot = (source: CheckSnapshotSource): CheckSnapshot => ({ id: source.id, status: source.status, @@ -82,4 +87,5 @@ export const toCheckSnapshot = (source: CheckSnapshotSource): CheckSnapshot => ( seo: source.seo, performance: source.performance, audits: mapAudits(source.audits), + containerSummary: mapContainerSummary(source.containerSummary), }); diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index aa1eb3782..77486aa79 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -1,5 +1,7 @@ import type { MonitorType } from "@/domain/monitors/monitor.type.js"; +import { DockerContainerInfo, DockerContainerSummary } from "@/types/network.js"; import type { Response } from "got"; +import { number } from "zod"; export const CHECK_TTL_SENTINEL = 366; @@ -107,6 +109,8 @@ export interface Check { host?: CheckHostInfo; errors?: CheckErrorInfo[]; capture?: CheckCaptureInfo; + containers?: DockerContainerInfo[]; + containerSummary?: DockerContainerSummary; net?: CheckNetworkInterfaceInfo[]; accessibility?: number; bestPractices?: number; @@ -197,6 +201,8 @@ export type CheckSnapshot = Pick< memory?: SnapshotMemoryInfo; disk?: SnapshotDiskInfo[]; host?: SnapshotHostInfo; + // Docker Only + containerSummary?: DockerContainerSummary; }; export interface HardwareDiskStats { name: string; @@ -252,3 +258,28 @@ export type DailyCheckBucket = { downChecks: number; avgResponseTime: number | null; }; + +export type DockerStatsBucket = { + _id: string; + avgResponseTime: number | null; + upCount: number; + totalCount: number; + avgRunning: number | null; + avgTotal: number | null; + avgUnhealthy: number | null; +}; + +export interface DockerStats { + aggregateData: { totalChecks: number }; + upChecks: { totalChecks: number }; + aggregate: DockerStatsBucket[]; + latest: { + containers: DockerContainerInfo[]; + summary?: DockerContainerSummary; + checkedAt: string; + } | null; +} + +export interface DockerChecksResult extends DockerStats { + monitorType: "docker"; +} diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index ba803dc67..89888900a 100644 --- a/server/src/domain/monitors/monitor.model.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -9,6 +9,7 @@ import type { SnapshotHostInfo, SnapshotMemoryInfo, } from "@/domain/checks/check.type.js"; +import { containerSummarySchema } from "@/domain/checks/check.model.js"; type CheckSnapshotDocument = Omit & { createdAt: Date }; @@ -113,6 +114,7 @@ const checkSnapshotSchema = new Schema( seo: { type: Number }, performance: { type: Number }, audits: { type: snapshotAuditsSchema }, + containerSummary: { type: containerSummarySchema }, createdAt: { type: Date, required: true }, }, { _id: false, suppressReservedKeysWarning: true } From 03174d98dd0c9462afef05048742a2428bfafa50 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 13:30:21 -0700 Subject: [PATCH 013/137] update monitors --- server/src/domain/monitors/monitor.service.ts | 7 ++++--- server/src/domain/monitors/monitor.type.ts | 20 +++++++++---------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index 6cf9af54b..33b8413db 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -9,7 +9,7 @@ import type { GroupedGeoCheckResult, } from "@/domain/monitors/monitor.type.js"; import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.type.js"; -import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult } from "@/domain/checks/check.type.js"; +import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult, DockerChecksResult } from "@/domain/checks/check.type.js"; import type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js"; import type { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; import type { IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js"; @@ -26,8 +26,9 @@ import { DateRange } from "@/types/query.js"; const SERVICE_NAME = "MonitorService"; -const isUptimeChecksResult = (result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult): result is UptimeChecksResult => - supportsUptimeDetails(result.monitorType); +const isUptimeChecksResult = ( + result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult | DockerChecksResult +): result is UptimeChecksResult => supportsUptimeDetails(result.monitorType); export interface IMonitorService { // create diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index 1767ba17f..e33f89c5c 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -1,9 +1,10 @@ -import type { CheckSnapshot } from "@/domain/checks/check.type.js"; +import type { CheckSnapshot, DockerStats } from "@/domain/checks/check.type.js"; export type { CheckSnapshot } from "@/domain/checks/check.type.js"; import type { GeoContinent, GroupedGeoCheck } from "@/domain/geo-checks/geo-check.type.js"; 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"; export const HttpStatusCodes = [ ...Object.keys(http.STATUS_CODES).map(Number), @@ -44,16 +45,7 @@ export const supportsGeoCheck = (type: MonitorType): boolean => GeoCheckSupporte export const ProxyModes = ["inherit", "none", "custom"] as const; export type ProxyMode = (typeof ProxyModes)[number]; -export const UptimeDetailsSupportedTypes = [ - "http", - "ping", - "docker", - "port", - "game", - "grpc", - "websocket", - "dns", -] as const satisfies readonly MonitorType[]; +export const UptimeDetailsSupportedTypes = ["http", "ping", "port", "game", "grpc", "websocket", "dns"] as const satisfies readonly MonitorType[]; export type UptimeDetailsSupportedType = (typeof UptimeDetailsSupportedTypes)[number]; export const supportsUptimeDetails = (type: MonitorType): type is UptimeDetailsSupportedType => UptimeDetailsSupportedTypes.some((t) => t === type); @@ -162,6 +154,12 @@ export interface HardwareDetailsResult { monitorStats: import("../monitor-stats/monitor-stats.type.js").MonitorStats | null; } +export interface DockerDetailsResult { + monitor: Monitor; + stats: DockerStats; + monitorStats: MonitorStats | null; +} + export interface PageSpeedDetailsResult { monitorData: { monitor: Monitor; From 1a1dc7d807565630cebcc5049d4eea54159a4812 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 13:30:32 -0700 Subject: [PATCH 014/137] add docker check aggregation --- .../domain/checks/check.docker.aggregation.ts | 54 +++++++++++++++++++ .../checks/check.repository.interface.ts | 3 +- 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 server/src/domain/checks/check.docker.aggregation.ts diff --git a/server/src/domain/checks/check.docker.aggregation.ts b/server/src/domain/checks/check.docker.aggregation.ts new file mode 100644 index 000000000..9edd1dcbb --- /dev/null +++ b/server/src/domain/checks/check.docker.aggregation.ts @@ -0,0 +1,54 @@ +import CheckModel from "@/domain/checks/check.model.js"; +import { DockerStatsBucket } from "@/domain/checks/check.type.js"; +import mongoose from "mongoose"; + +type DateRange = { start: Date; end: Date }; + +export const getDockerTotalChecks = async (monitorId: string, dates: DateRange): Promise => + CheckModel.countDocuments({ + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + "metadata.type": "docker", + createdAt: { $gte: dates.start, $lte: dates.end }, + }); + +export const getDockerUpChecks = async (monitorId: string, dates: DateRange): Promise<{ totalChecks: number }> => { + const count = await CheckModel.countDocuments({ + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + "metadata.type": "docker", + createdAt: { $gte: dates.start, $lte: dates.end }, + status: true, + }); + return { totalChecks: count }; +}; + +export const getDockerStats = async (monitorId: string, dates: DateRange, dateString: string): Promise => + CheckModel.aggregate([ + { + $match: { + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + "metadata.type": "docker", + createdAt: { $gte: dates.start, $lte: dates.end }, + }, + }, + { + $group: { + _id: { $dateToString: { format: dateString, date: "$createdAt" } }, + avgResponseTime: { $avg: "$responseTime" }, + upCount: { $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] } }, + totalCount: { $sum: 1 }, + avgRunning: { $avg: "$containerSummary.running" }, + avgTotal: { $avg: "$containerSummary.total" }, + avgUnhealthy: { $avg: "$containerSummary.unhealthy" }, + }, + }, + { $sort: { _id: 1 } }, + ]); + +export const getDockerLatestCheck = async (monitorId: string) => + CheckModel.findOne({ + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + "metadata.type": "docker", + }) + .sort({ createdAt: -1 }) + .select("containers containerSummary createdAt") + .lean(); diff --git a/server/src/domain/checks/check.repository.interface.ts b/server/src/domain/checks/check.repository.interface.ts index 1767b2ea1..298981216 100644 --- a/server/src/domain/checks/check.repository.interface.ts +++ b/server/src/domain/checks/check.repository.interface.ts @@ -3,6 +3,7 @@ import type { ChecksQueryResult, ChecksSummary, DailyCheckBucket, + DockerChecksResult, HardwareChecksResult, PageSpeedChecksResult, UptimeChecksResult, @@ -38,7 +39,7 @@ export interface IChecksRepository { monitorId: string, dateRange: DateRange, options?: { type?: MonitorType } - ): Promise; + ): Promise; findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise; findUnevaluatedByMonitorId(monitorId: string, since: number): Promise; getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string): Promise; From 78d3b23e930616d24175a882bdcc8e5bd942a4c7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 13:48:37 -0700 Subject: [PATCH 015/137] handle docker checks --- .../domain/checks/check.repository.mongo.ts | 30 +++++++++++++- server/src/domain/checks/check.type.ts | 4 +- server/src/domain/monitors/monitor.service.ts | 40 +++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index 9b46f2dbd..b779a2295 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -24,6 +24,7 @@ import { getHardwareUpChecks, getHardwareStats, getHardwareTotalChecks } from "@ import { CheckFilter, DateRange } from "@/types/query.js"; import { AppError } from "@/utils/AppError.js"; import { NETWORK_ERROR } from "@/types/network.js"; +import { getDockerLatestCheck, getDockerStats, getDockerTotalChecks, getDockerUpChecks } from "@/domain/checks/check.docker.aggregation.js"; const SERVICE_NAME = "ChecksRepository"; @@ -304,6 +305,9 @@ class MongoChecksRepository implements IChecksRepository { if (options?.type === "pagespeed") { return this.findPageSpeedDateRangeChecks(monitorObjectId, start, end, dateString); } + if (options?.type === "docker") { + return this.findDockerDateRangeChecks(monitorObjectId, start, end, dateString); + } return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorObjectId, start, end, dateString); }; @@ -414,7 +418,7 @@ class MongoChecksRepository implements IChecksRepository { return totalDeleted; }; private findUptimeDateRangeChecks = async ( - monitorType: Exclude, + monitorType: Exclude, monitorObjectId: mongoose.Types.ObjectId, startDate: Date, endDate: Date, @@ -630,6 +634,30 @@ class MongoChecksRepository implements IChecksRepository { groupedChecks: result?.groupedChecks ?? [], }; }; + + private findDockerDateRangeChecks = async (monitorObjectId: mongoose.Types.ObjectId, startDate: Date, endDate: Date, dateString: string) => { + const monitorId = monitorObjectId.toHexString(); + const dates = { start: startDate, end: endDate }; + const [totalChecks, upChecks, aggregate, latestDoc] = await Promise.all([ + getDockerTotalChecks(monitorId, dates), + getDockerUpChecks(monitorId, dates), + getDockerStats(monitorId, dates, dateString), + getDockerLatestCheck(monitorId), + ]); + return { + monitorType: "docker" as const, + aggregateData: { totalChecks: totalChecks ?? 0 }, + upChecks, + aggregate, + latest: latestDoc + ? { + containers: latestDoc.containers ?? [], + summary: latestDoc.containerSummary, + checkedAt: toDateString(latestDoc.createdAt), + } + : null, + }; + }; } export default MongoChecksRepository; diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index 77486aa79..00ab3641d 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -1,7 +1,6 @@ import type { MonitorType } from "@/domain/monitors/monitor.type.js"; import { DockerContainerInfo, DockerContainerSummary } from "@/types/network.js"; import type { Response } from "got"; -import { number } from "zod"; export const CHECK_TTL_SENTINEL = 366; @@ -151,11 +150,10 @@ export interface PageSpeedGroupedCheck { accessibility: number; bestPractices: number; seo: number; - totalChecks: number; } export interface UptimeChecksResult { - monitorType: Exclude; + monitorType: Exclude; groupedChecks: GroupedUptimeCheck[]; groupedUpChecks: GroupedCheck[]; groupedDownChecks: GroupedCheck[]; diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index 33b8413db..c0ff1e20f 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -7,6 +7,7 @@ import type { PageSpeedDetailsResult, GamesMap, GroupedGeoCheckResult, + DockerDetailsResult, } from "@/domain/monitors/monitor.type.js"; import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.type.js"; import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult, DockerChecksResult } from "@/domain/checks/check.type.js"; @@ -40,6 +41,7 @@ export interface IMonitorService { getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; + getDockerDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; getGeoChecksByMonitorId(args: { teamId: string; monitorId: string; @@ -282,6 +284,44 @@ export class MonitorService implements IMonitorService { monitorStats, }; }; + getDockerDetailsById = async ({ + teamId, + monitorId, + dateRange, + }: { + teamId: string; + monitorId: string; + dateRange: DateRange; + }): 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 details`, status: 400 }); + } + + const checksData = await this.checksRepository.findByDateRangeAndMonitorId(monitor.id, dateRange, { + type: monitor.type, + }); + + if (checksData.monitorType !== "docker") { + throw new AppError({ message: "Unable to load docker stats for this monitor", status: 500 }); + } + + const monitorStats = await this.monitorStatsRepository.findByMonitorId(monitor.id); + + return { + monitor, + stats: { + aggregateData: checksData.aggregateData, + upChecks: checksData.upChecks, + aggregate: checksData.aggregate, + latest: checksData.latest, + }, + monitorStats, + }; + }; getGeoChecksByMonitorId = async ({ teamId, From 4ec03dfd046a5624f2a3ad3b7e92770c632e4c63 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 13:49:44 -0700 Subject: [PATCH 016/137] fix return type, copy paste bug --- server/src/domain/monitors/monitor.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index c0ff1e20f..14b488af2 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -41,7 +41,7 @@ export interface IMonitorService { getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; - getDockerDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; + getDockerDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; getGeoChecksByMonitorId(args: { teamId: string; monitorId: string; From cacedfd7d867f74d92a025253fc9d9d2f7699851 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 13:59:24 -0700 Subject: [PATCH 017/137] add validation --- .../src/api/validation/monitorValidation.ts | 92 ++++++++++++++++++- 1 file changed, 89 insertions(+), 3 deletions(-) diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index e694a216d..48477f731 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -13,6 +13,7 @@ import { ProxyModes, } from "@/domain/monitors/monitor.type.js"; import { DateRanges, SortOrders } from "@/types/query.js"; +import { DockerContainerStates, DockerHealthStatuses } from "@/types/network.js"; const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" }); @@ -118,6 +119,26 @@ const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ct } }; +const dockerUrlRegex = /^(?:ssh:\/\/[^@\s]+@[^\s/:@]+(?::\d{1,5})?\/?|unix:\/\/\/\S+|\/\S+)$/; + +const refineDockerUrl = (data: { type?: string; url?: string; sshPrivateKey?: 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", + }); + } +}; + export const createMonitorBodyValidation = z .object({ _id: z.string().optional(), @@ -142,6 +163,7 @@ 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(), @@ -161,7 +183,8 @@ export const createMonitorBodyValidation = z .superRefine(refineStrategyType) .superRefine(refineHeadMatching) .superRefine(refineRegexPattern) - .superRefine(refineProxySelection); + .superRefine(refineProxySelection) + .superRefine(refineDockerUrl); export const editMonitorBodyValidation = z .object({ @@ -176,6 +199,7 @@ 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, @@ -204,7 +228,8 @@ export const editMonitorBodyValidation = z .superRefine(refineStrategyType) .superRefine(refineHeadMatching) .superRefine(refineRegexPattern) - .superRefine(refineProxySelection); + .superRefine(refineProxySelection) + .superRefine(refineDockerUrl); export const pauseMonitorParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required"), @@ -255,6 +280,7 @@ 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), @@ -280,7 +306,8 @@ const importedMonitorSchema = z .superRefine(refineStrategyType) .superRefine(refineHeadMatching) .superRefine(refineRegexPattern) - .superRefine(refineProxySelection); + .superRefine(refineProxySelection) + .superRefine(refineDockerUrl); export const importMonitorsBodyValidation = z.object({ monitors: z.array(importedMonitorSchema).min(1, "At least one monitor is required"), @@ -296,6 +323,9 @@ export const getHardwareDetailsByIdQueryValidation = z.object({ dateRange: z.enum(DateRanges).optional(), }); +export const getDockerDetailsByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required") }); +export const getDockerDetailsByIdQueryValidation = z.object({ dateRange: z.enum(DateRanges).optional() }); + // Canonical monitor shape returned by /monitors endpoints. Keep aligned with // what the controllers actually serialize. export const monitorResponseSchema = z @@ -323,6 +353,7 @@ 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(), @@ -392,3 +423,58 @@ export const uptimeDetailsResponseSchema = z.object({ }), monitorStats: monitorStatsResponseSchema.nullable(), }); + +// Keep aligned with DockerContainerInfo / DockerContainerSummary in types/network.ts. +export const dockerContainerResponseSchema = z.object({ + id: z.string(), + name: z.string(), + image: z.string(), + state: z.enum(DockerContainerStates), + status: z.string(), + health: z.enum(DockerHealthStatuses), + cpuPct: z.number().optional(), + memoryUsedBytes: z.number().optional(), + memoryLimitBytes: z.number().optional(), + memoryPct: z.number().optional(), + restartCount: z.number().optional(), + startedAt: z.string().optional(), +}); + +export const containerSummaryResponseSchema = z.object({ + total: z.number(), + running: z.number(), + stopped: z.number(), + unhealthy: z.number(), +}); + +// Keep aligned with DockerStatsBucket in domain/checks/check.type.ts. The avg fields are +// null for buckets with no values ($avg skips missing; down checks store no containerSummary). +export const dockerStatsBucketResponseSchema = z.object({ + _id: z.string(), + avgResponseTime: z.number().nullable(), + upCount: z.number(), + totalCount: z.number(), + avgRunning: z.number().nullable(), + avgTotal: z.number().nullable(), + avgUnhealthy: z.number().nullable(), +}); + +// Response of GET /monitors/docker/details/{monitorId}. Keep aligned with +// DockerDetailsResult in domain/monitors/monitor.type.ts; the monitor here is the +// repository's domain entity, which serializes `id` rather than `_id`. +export const dockerDetailsResponseSchema = z.object({ + monitor: monitorResponseSchema.omit({ _id: true }).extend({ id: z.string() }), + stats: z.object({ + aggregateData: z.object({ totalChecks: z.number() }), + upChecks: z.object({ totalChecks: z.number() }), + aggregate: z.array(dockerStatsBucketResponseSchema), + latest: z + .object({ + containers: z.array(dockerContainerResponseSchema), + summary: containerSummaryResponseSchema.optional(), + checkedAt: z.string(), + }) + .nullable(), + }), + monitorStats: monitorStatsResponseSchema.nullable(), +}); From c8826d90d5f0ae3c6ac9f4f6da1ca64e519fb209 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 13:59:54 -0700 Subject: [PATCH 018/137] update openapi --- server/openapi/routes/monitor.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/openapi/routes/monitor.ts b/server/openapi/routes/monitor.ts index ddb207eb8..fb388c909 100644 --- a/server/openapi/routes/monitor.ts +++ b/server/openapi/routes/monitor.ts @@ -16,6 +16,9 @@ import { importMonitorsBodyValidation, getHardwareDetailsByIdParamValidation, getHardwareDetailsByIdQueryValidation, + getDockerDetailsByIdParamValidation, + getDockerDetailsByIdQueryValidation, + dockerDetailsResponseSchema, monitorResponseSchema, uptimeDetailsResponseSchema, } from "@/api/validation/monitorValidation.js"; @@ -105,6 +108,16 @@ registry.registerPath({ responses: { "200": okUnknown, ...standardErrors }, }); +registry.registerPath({ + method: "get", + path: "/monitors/docker/details/{monitorId}", + tags, + summary: "Get Docker host details for a monitor", + security: bearer, + request: { params: getDockerDetailsByIdParamValidation, query: getDockerDetailsByIdQueryValidation }, + responses: { "200": okJson(dockerDetailsResponseSchema), ...standardErrors }, +}); + registry.registerPath({ method: "get", path: "/monitors/pagespeed/details/{monitorId}", From 560d64a482436946ac62c1c50164d07f4170f235 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 14:00:34 -0700 Subject: [PATCH 019/137] add controller method for docker monitors --- .../src/api/controllers/monitorController.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/server/src/api/controllers/monitorController.ts b/server/src/api/controllers/monitorController.ts index f05e0df5c..d636830e2 100644 --- a/server/src/api/controllers/monitorController.ts +++ b/server/src/api/controllers/monitorController.ts @@ -17,6 +17,8 @@ import { getUptimeDetailsByIdQueryValidation, importMonitorsBodyValidation, bulkPauseMonitorBodyValidation, + getDockerDetailsByIdParamValidation, + getDockerDetailsByIdQueryValidation, } from "@/api/validation/monitorValidation.js"; import sslChecker from "ssl-checker"; import * as whoiser from "whoiser"; @@ -31,6 +33,7 @@ export interface IMonitorController { getUptimeDetailsById: RequestHandler; getHardwareDetailsById: RequestHandler; getPageSpeedDetailsById: RequestHandler; + getDockerDetailsById: RequestHandler; getGeoChecksByMonitorId: RequestHandler; getMonitorById: RequestHandler; createMonitor: RequestHandler; @@ -149,6 +152,27 @@ class MonitorController implements IMonitorController { }); }); + getDockerDetailsById = catchAsync(async (req: Request, res: Response) => { + const validatedParams = getDockerDetailsByIdParamValidation.parse(req.params); + const validatedQuery = getDockerDetailsByIdQueryValidation.parse(req.query); + + const monitorId = validatedParams.monitorId; + const dateRange = validatedQuery.dateRange || "recent"; + const teamId = requireTeamId(req.user?.teamId); + + const data = await this.monitorService.getDockerDetailsById({ + teamId, + monitorId, + dateRange, + }); + + return res.status(200).json({ + success: true, + msg: "Docker details retrieved successfully", + data: data, + }); + }); + getGeoChecksByMonitorId = catchAsync(async (req: Request, res: Response) => { const validatedParams = getMonitorByIdParamValidation.parse(req.params); const validatedQuery = getMonitorByIdQueryValidation.parse(req.query); From e60bfd18ff7aa177bc996f9c3075c9b0bc67df4f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 14:03:27 -0700 Subject: [PATCH 020/137] add route --- server/openapi.json | 540 +++++++++++++++++++++++++ server/src/api/routes/monitorRoutes.ts | 3 + 2 files changed, 543 insertions(+) diff --git a/server/openapi.json b/server/openapi.json index c2408b580..de869c314 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -422,6 +422,9 @@ "secret": { "type": "string" }, + "sshPrivateKey": { + "type": "string" + }, "cpuAlertThreshold": { "type": "number" }, @@ -637,6 +640,9 @@ "secret": { "type": "string" }, + "sshPrivateKey": { + "type": "string" + }, "cpuAlertThreshold": { "type": "number" }, @@ -1648,6 +1654,24 @@ }, "performance": { "type": "number" + }, + "containerSummary": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "running": { + "type": "number" + }, + "stopped": { + "type": "number" + }, + "unhealthy": { + "type": "number" + } + }, + "required": ["total", "running", "stopped", "unhealthy"] } }, "required": ["id", "status", "responseTime", "statusCode", "message", "createdAt"], @@ -6936,6 +6960,513 @@ } } }, + "/monitors/docker/details/{monitorId}": { + "get": { + "tags": ["monitors"], + "summary": "Get Docker host details for a monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": false, + "name": "dateRange", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "monitor": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] + }, + "url": { + "type": "string" + }, + "port": { + "type": "number" + }, + "isActive": { + "type": "boolean" + }, + "interval": { + "type": "number" + }, + "status": { + "type": "string", + "enum": ["up", "down", "paused", "initializing", "maintenance", "breached"] + }, + "statusWindowSize": { + "type": "number" + }, + "statusWindowThreshold": { + "type": "number" + }, + "ignoreTlsErrors": { + "type": "boolean" + }, + "proxyMode": { + "type": "string", + "enum": ["inherit", "none", "custom"] + }, + "proxyId": { + "type": "string" + }, + "useAdvancedMatching": { + "type": "boolean" + }, + "jsonPath": { + "type": "string" + }, + "expectedValue": { + "type": "string" + }, + "matchMethod": { + "type": "string", + "enum": ["equal", "include", "regex"] + }, + "method": { + "type": "string", + "enum": ["GET", "HEAD"] + }, + "notifications": { + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "customUpCodes": { + "type": "array", + "items": { + "type": "number" + } + }, + "secret": { + "type": "string" + }, + "sshPrivateKey": { + "type": "string" + }, + "cpuAlertThreshold": { + "type": "number" + }, + "memoryAlertThreshold": { + "type": "number" + }, + "diskAlertThreshold": { + "type": "number" + }, + "tempAlertThreshold": { + "type": "number" + }, + "selectedDisks": { + "type": "array", + "items": { + "type": "string" + } + }, + "gameId": { + "type": "string" + }, + "grpcServiceName": { + "type": "string" + }, + "group": { + "type": "string", + "nullable": true + }, + "geoCheckEnabled": { + "type": "boolean" + }, + "geoCheckLocations": { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + } + }, + "geoCheckInterval": { + "type": "number" + }, + "dnsServer": { + "type": "string" + }, + "dnsRecordType": { + "type": "string", + "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] + }, + "teamId": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "lastEvaluatedAt": { + "type": "number" + }, + "id": { + "type": "string" + } + }, + "required": [ + "name", + "type", + "url", + "isActive", + "interval", + "status", + "statusWindowSize", + "statusWindowThreshold", + "ignoreTlsErrors", + "proxyMode", + "useAdvancedMatching", + "method", + "notifications", + "tags", + "cpuAlertThreshold", + "memoryAlertThreshold", + "diskAlertThreshold", + "tempAlertThreshold", + "selectedDisks", + "geoCheckEnabled", + "geoCheckLocations", + "geoCheckInterval", + "teamId", + "userId", + "createdAt", + "updatedAt", + "lastEvaluatedAt", + "id" + ], + "additionalProperties": { + "nullable": true + } + }, + "stats": { + "type": "object", + "properties": { + "aggregateData": { + "type": "object", + "properties": { + "totalChecks": { + "type": "number" + } + }, + "required": ["totalChecks"] + }, + "upChecks": { + "type": "object", + "properties": { + "totalChecks": { + "type": "number" + } + }, + "required": ["totalChecks"] + }, + "aggregate": { + "type": "array", + "items": { + "type": "object", + "properties": { + "_id": { + "type": "string" + }, + "avgResponseTime": { + "type": "number", + "nullable": true + }, + "upCount": { + "type": "number" + }, + "totalCount": { + "type": "number" + }, + "avgRunning": { + "type": "number", + "nullable": true + }, + "avgTotal": { + "type": "number", + "nullable": true + }, + "avgUnhealthy": { + "type": "number", + "nullable": true + } + }, + "required": ["_id", "avgResponseTime", "upCount", "totalCount", "avgRunning", "avgTotal", "avgUnhealthy"] + } + }, + "latest": { + "type": "object", + "nullable": true, + "properties": { + "containers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "state": { + "type": "string", + "enum": ["created", "running", "paused", "restarting", "removing", "exited", "dead"] + }, + "status": { + "type": "string" + }, + "health": { + "type": "string", + "enum": ["healthy", "unhealthy", "starting", "none"] + }, + "cpuPct": { + "type": "number" + }, + "memoryUsedBytes": { + "type": "number" + }, + "memoryLimitBytes": { + "type": "number" + }, + "memoryPct": { + "type": "number" + }, + "restartCount": { + "type": "number" + }, + "startedAt": { + "type": "string" + } + }, + "required": ["id", "name", "image", "state", "status", "health"] + } + }, + "summary": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "running": { + "type": "number" + }, + "stopped": { + "type": "number" + }, + "unhealthy": { + "type": "number" + } + }, + "required": ["total", "running", "stopped", "unhealthy"] + }, + "checkedAt": { + "type": "string" + } + }, + "required": ["containers", "checkedAt"] + } + }, + "required": ["aggregateData", "upChecks", "aggregate", "latest"] + }, + "monitorStats": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "string" + }, + "monitorId": { + "type": "string" + }, + "avgResponseTime": { + "type": "number" + }, + "maxResponseTime": { + "type": "number" + }, + "totalChecks": { + "type": "number" + }, + "totalUpChecks": { + "type": "number" + }, + "totalDownChecks": { + "type": "number" + }, + "uptimePercentage": { + "type": "number" + }, + "lastCheckTimestamp": { + "type": "number" + }, + "lastResponseTime": { + "type": "number" + }, + "timeOfLastFailure": { + "type": "number" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "monitorId", + "avgResponseTime", + "maxResponseTime", + "totalChecks", + "totalUpChecks", + "totalDownChecks", + "uptimePercentage", + "lastCheckTimestamp", + "lastResponseTime", + "createdAt", + "updatedAt" + ] + } + }, + "required": ["monitor", "stats", "monitorStats"] + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, "/monitors/pagespeed/details/{monitorId}": { "get": { "tags": ["monitors"], @@ -7860,6 +8391,9 @@ "secret": { "type": "string" }, + "sshPrivateKey": { + "type": "string" + }, "jsonPath": { "anyOf": [ { @@ -8553,6 +9087,9 @@ "secret": { "type": "string" }, + "sshPrivateKey": { + "type": "string" + }, "cpuAlertThreshold": { "type": "number", "default": 100 @@ -9083,6 +9620,9 @@ "secret": { "type": "string" }, + "sshPrivateKey": { + "type": "string" + }, "ignoreTlsErrors": { "type": "boolean" }, diff --git a/server/src/api/routes/monitorRoutes.ts b/server/src/api/routes/monitorRoutes.ts index 4cd90a9a0..6da47f2a7 100644 --- a/server/src/api/routes/monitorRoutes.ts +++ b/server/src/api/routes/monitorRoutes.ts @@ -18,6 +18,9 @@ export const createMonitorRoutes = (monitorController: IMonitorController): Rout // PageSpeed routes router.get("/pagespeed/details/:monitorId", monitorController.getPageSpeedDetailsById); + // Docker routes + router.get("/docker/details/:monitorId", monitorController.getDockerDetailsById); + // Geo checks routes router.get("/:monitorId/geo-checks", monitorController.getGeoChecksByMonitorId); From e964234f544ca34cc57954e3455a8059e2b3aa13 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 14:42:46 -0700 Subject: [PATCH 021/137] add tests and migration --- .../0012_migrateDockerMonitorUrls.ts | 36 ++++++++ server/src/db/migration/index.ts | 2 + .../test/unit/services/checkService.test.ts | 27 ++++++ .../test/unit/services/monitorService.test.ts | 92 ++++++++++++++++++- .../unit/validation/monitorValidation.test.ts | 75 +++++++++++++++ 5 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 server/src/db/migration/0012_migrateDockerMonitorUrls.ts diff --git a/server/src/db/migration/0012_migrateDockerMonitorUrls.ts b/server/src/db/migration/0012_migrateDockerMonitorUrls.ts new file mode 100644 index 000000000..75f3d5acf --- /dev/null +++ b/server/src/db/migration/0012_migrateDockerMonitorUrls.ts @@ -0,0 +1,36 @@ +import { MonitorModel } from "../../domain/monitors/monitor.model.js"; +import type { ILogger } from "@/utils/logger.js"; + +/** + * Rewrite docker monitor urls from the old container-name semantics to host urls. + * + * 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. + */ +export async function migrateDockerMonitorUrls(logger: ILogger): Promise { + const SERVICE_NAME = "Migration:MigrateDockerMonitorUrls"; + + try { + logger.info({ service: SERVICE_NAME, message: "Starting docker monitor url migration" }); + + const result = await MonitorModel.updateMany({ type: "docker", url: { $not: /^((unix|ssh):\/\/|\/)/ } }, [ + { + $set: { + description: { + $concat: [{ $ifNull: ["$description", ""] }, " (was container: ", "$url", ")"], + }, + url: "unix:///var/run/docker.sock", + }, + }, + ]); + + logger.info({ service: SERVICE_NAME, message: `Migrated ${result.modifiedCount} docker monitors to host urls` }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ service: SERVICE_NAME, message: `Error migrating docker monitor urls: ${errorMessage}` }); + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index 94829241d..c4233948c 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -10,6 +10,7 @@ import { backfillMonitorLastEvaluatedAt } from "./0008_backfillMonitorLastEvalua import { recomputeResponseTimeFromTimings } from "./0009_recomputeResponseTimeFromTimings.js"; import { slimRecentChecks } from "./0010_slimRecentChecks.js"; import { backfillMonitorProxyMode } from "./0011_backfillMonitorProxyMode.js"; +import { migrateDockerMonitorUrls } from "./0012_migrateDockerMonitorUrls.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { @@ -29,6 +30,7 @@ const migrations: MigrationEntry[] = [ { name: "0009_recomputeResponseTimeFromTimings", execute: recomputeResponseTimeFromTimings }, { name: "0010_slimRecentChecks", execute: slimRecentChecks }, { name: "0011_backfillMonitorProxyMode", execute: backfillMonitorProxyMode }, + { name: "0012_migrateDockerMonitorUrls", execute: migrateDockerMonitorUrls }, ]; const runMigrations = async (logger: ILogger) => { diff --git a/server/test/unit/services/checkService.test.ts b/server/test/unit/services/checkService.test.ts index 80b941b6f..4ced65947 100644 --- a/server/test/unit/services/checkService.test.ts +++ b/server/test/unit/services/checkService.test.ts @@ -252,6 +252,33 @@ describe("CheckService", () => { expect(check!.errors).toBeUndefined(); }); }); + + describe("docker type", () => { + it("extracts containers and containerSummary from payload", () => { + const payload = { + containers: [ + { id: "abc", name: "web", image: "nginx:latest", state: "running", status: "Up 3 hours", health: "healthy" }, + { id: "def", name: "db", image: "mongo:8.0", state: "exited", status: "Exited (0)", health: "none" }, + ], + summary: { total: 2, running: 1, stopped: 1, unhealthy: 0 }, + } as any; + const { service } = createService(); + const check = service.toCheck(makeStatusResponse({ type: "docker", payload } as any)); + + expect(check!.containers).toHaveLength(2); + expect(check!.containers![0]).toMatchObject({ id: "abc", state: "running", health: "healthy" }); + expect(check!.containerSummary).toEqual({ total: 2, running: 1, stopped: 1, unhealthy: 0 }); + }); + + it("handles a null payload gracefully (down check)", () => { + const { service } = createService(); + const check = service.toCheck(makeStatusResponse({ type: "docker", payload: null } as any)); + + expect(check).toBeDefined(); + expect(check!.containers).toBeUndefined(); + expect(check!.containerSummary).toBeUndefined(); + }); + }); }); // ── toStatusResponse round-trip (Phase 2 §5 guard) ───────────────────────── diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index fcfdf533d..c725fcd10 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -237,8 +237,8 @@ describe("MonitorService", () => { expect(result.monitorData.monitor).toMatchObject({ id: MONITOR_ID }); }); - it("supports all uptime monitor types: ping, docker, port, game, grpc, websocket", async () => { - for (const monitorType of ["ping", "docker", "port", "game", "grpc", "websocket"] as const) { + it("supports all uptime monitor types: ping, port, game, grpc, websocket, dns", async () => { + for (const monitorType of ["ping", "port", "game", "grpc", "websocket", "dns"] as const) { const monitorsRepository = createMonitorsRepositoryMock(); const checksRepository = createChecksRepositoryMock(); const monitorStatsRepository = createMonitorStatsRepositoryMock(); @@ -314,6 +314,27 @@ describe("MonitorService", () => { ); }); + it("throws 400 for unsupported monitor type (docker)", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const monitor = makeMonitor({ type: "docker" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ + monitorType: "docker", + aggregateData: { totalChecks: 0 }, + upChecks: { totalChecks: 0 }, + aggregate: [], + latest: null, + }); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue(null); + + const { service } = createService({ monitorsRepository, checksRepository, monitorStatsRepository }); + await expect(service.getUptimeDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" })).rejects.toThrow( + "monitors are not supported for uptime details" + ); + }); + it("uses different date ranges correctly", async () => { for (const dateRange of ["recent", "day", "week", "month", "all"] as const) { const monitorsRepository = createMonitorsRepositoryMock(); @@ -398,6 +419,73 @@ describe("MonitorService", () => { }); }); + describe("getDockerDetailsById", () => { + it("returns docker details for a docker monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitorStatsRepository = createMonitorStatsRepositoryMock(); + const monitor = makeMonitor({ type: "docker" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ + monitorType: "docker", + aggregateData: { totalChecks: 10 }, + upChecks: { totalChecks: 9 }, + aggregate: [{ _id: "2024-01-01T00:00:00Z", avgResponseTime: 12, upCount: 9, totalCount: 10, avgRunning: 3, avgTotal: 4, avgUnhealthy: 0 }], + latest: { + containers: [], + summary: { total: 4, running: 3, stopped: 1, unhealthy: 0 }, + checkedAt: "2024-01-01T00:00:00Z", + }, + }); + (monitorStatsRepository.findByMonitorId as jest.Mock).mockResolvedValue({ monitorId: MONITOR_ID }); + + const { service } = createService({ monitorsRepository, checksRepository, monitorStatsRepository }); + const result = await service.getDockerDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" }); + + expect(result.monitor).toMatchObject({ id: MONITOR_ID }); + expect(result.stats).toHaveProperty("aggregateData"); + expect(result.stats).toHaveProperty("upChecks"); + expect(result.stats).toHaveProperty("aggregate"); + expect(result.stats).toHaveProperty("latest"); + expect(result.stats.latest?.summary).toMatchObject({ total: 4, running: 3, stopped: 1, unhealthy: 0 }); + expect(result.monitorStats).toMatchObject({ monitorId: MONITOR_ID }); + }); + + it("throws 404 when monitor not found", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(null); + const { service } = createService({ monitorsRepository }); + + await expect(service.getDockerDetailsById({ teamId: TEAM_ID, monitorId: "missing", dateRange: "recent" })).rejects.toThrow( + "Monitor with ID missing not found." + ); + }); + + it("throws 400 when monitor type is not docker", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const monitor = makeMonitor({ type: "http" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + const { service } = createService({ monitorsRepository }); + + await expect(service.getDockerDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" })).rejects.toThrow( + "monitors are not supported for docker details" + ); + }); + + it("throws 500 when checksData monitorType is not docker", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitor = makeMonitor({ type: "docker" }); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findByDateRangeAndMonitorId as jest.Mock).mockResolvedValue({ monitorType: "http" }); + const { service } = createService({ monitorsRepository, checksRepository }); + + await expect(service.getDockerDetailsById({ teamId: TEAM_ID, monitorId: MONITOR_ID, dateRange: "recent" })).rejects.toThrow( + "Unable to load docker stats for this monitor" + ); + }); + }); + describe("getPageSpeedDetailsById", () => { it("returns pagespeed details for a pagespeed monitor", async () => { const monitorsRepository = createMonitorsRepositoryMock(); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index ea2257072..2b6f994d7 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -565,3 +565,78 @@ describe("monitorValidation — proxy fields", () => { }); }); }); + +describe("monitorValidation — Docker host url", () => { + const baseDockerBody = { + name: "Docker host check", + 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", () => { + const parsed = createMonitorBodyValidation.parse(baseDockerBody); + expect(parsed.url).toBe("unix:///var/run/docker.sock"); + }); + + it("accepts a bare absolute socket path", () => { + const parsed = createMonitorBodyValidation.parse({ ...baseDockerBody, url: "/run/docker.sock" }); + 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", ""]) { + 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(); + }); + }); + + describe("editMonitorBodyValidation", () => { + it("allows a docker edit without a url (partial edit)", () => { + const parsed = editMonitorBodyValidation.parse({ type: "docker", name: "renamed" }); + expect(parsed.url).toBeUndefined(); + }); + + it("rejects a docker edit with an invalid url", () => { + expect(() => editMonitorBodyValidation.parse({ type: "docker", url: "my-container" })).toThrow(); + }); + + it("rejects a docker edit switching to ssh without a key", () => { + expect(() => editMonitorBodyValidation.parse({ type: "docker", url: "ssh://deploy@host" })).toThrow(); + }); + }); + + describe("importMonitorsBodyValidation", () => { + it("accepts an imported docker monitor with a socket url", () => { + const parsed = importMonitorsBodyValidation.parse({ monitors: [baseDockerBody] }); + expect(parsed.monitors[0].url).toBe("unix:///var/run/docker.sock"); + }); + + it("rejects an imported docker monitor with a container-name url", () => { + expect(() => importMonitorsBodyValidation.parse({ monitors: [{ ...baseDockerBody, url: "my-container" }] })).toThrow(); + }); + }); +}); From 791a0c8483588ae78f446756cbcfd575829ae904 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 28 Aug 2026 15:08:23 -0700 Subject: [PATCH 022/137] add docker monitors page, table, and details skeleton --- .../Components/monitors/ControlsFilter.tsx | 3 +- client/src/Components/sidebar/Menu.tsx | 6 + client/src/Features/UI/uiSlice.ts | 7 +- client/src/Pages/Docker/Details/index.tsx | 5 + .../Components/DockerMonitorsTable.tsx | 347 ++++++++++++++++++ client/src/Pages/Docker/Monitors/index.tsx | 44 +++ client/src/Routes/index.tsx | 20 + client/src/Types/Check.ts | 10 + client/src/Types/Monitor.ts | 1 - client/src/Utils/MonitorUtils.ts | 2 +- client/src/locales/en.json | 19 + 11 files changed, 459 insertions(+), 5 deletions(-) create mode 100644 client/src/Pages/Docker/Details/index.tsx create mode 100644 client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx create mode 100644 client/src/Pages/Docker/Monitors/index.tsx diff --git a/client/src/Components/monitors/ControlsFilter.tsx b/client/src/Components/monitors/ControlsFilter.tsx index 196ef2042..d746150ed 100644 --- a/client/src/Components/monitors/ControlsFilter.tsx +++ b/client/src/Components/monitors/ControlsFilter.tsx @@ -9,12 +9,11 @@ import type { Tag } from "@/Types/Tag"; import { Typography, useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; -const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"]; +const types = ["http", "ping", "port", "game", "grpc", "websocket"]; const typeDisplayNames: Record = { http: "HTTP", ping: "Ping", port: "Port", - docker: "Docker", game: "Game", grpc: "gRPC", websocket: "WebSocket", diff --git a/client/src/Components/sidebar/Menu.tsx b/client/src/Components/sidebar/Menu.tsx index 93cd94623..b86fa9a63 100644 --- a/client/src/Components/sidebar/Menu.tsx +++ b/client/src/Components/sidebar/Menu.tsx @@ -19,6 +19,7 @@ import { Users, Tag, Waypoints, + Container, } from "lucide-react"; export const getMenu = (t: Function) => { return [ @@ -37,6 +38,11 @@ export const getMenu = (t: Function) => { path: "infrastructure", icon: , }, + { + name: t("components.sidebar.menu.docker"), + path: "docker", + icon: , + }, { name: t("components.sidebar.menu.notifications"), path: "notifications", diff --git a/client/src/Features/UI/uiSlice.ts b/client/src/Features/UI/uiSlice.ts index f759b2957..a07b629f8 100644 --- a/client/src/Features/UI/uiSlice.ts +++ b/client/src/Features/UI/uiSlice.ts @@ -8,7 +8,8 @@ type TableName = | "maintenance" | "infrastructure" | "logs" - | "pagespeed"; + | "pagespeed" + | "docker"; interface TableState { rowsPerPage: number; @@ -24,6 +25,7 @@ interface UIState { team: TableState; maintenance: TableState; infrastructure: TableState; + docker: TableState; logs: TableState; sidebar: SidebarState; mode: ThemeMode; @@ -56,6 +58,9 @@ const initialState: UIState = { infrastructure: { rowsPerPage: 5, }, + docker: { + rowsPerPage: 5, + }, logs: { rowsPerPage: 15, }, diff --git a/client/src/Pages/Docker/Details/index.tsx b/client/src/Pages/Docker/Details/index.tsx new file mode 100644 index 000000000..e90554b97 --- /dev/null +++ b/client/src/Pages/Docker/Details/index.tsx @@ -0,0 +1,5 @@ +const DockerDetails = () => { + return null; +}; + +export default DockerDetails; diff --git a/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx b/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx new file mode 100644 index 000000000..33fc65706 --- /dev/null +++ b/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx @@ -0,0 +1,347 @@ +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { + Table, + Pagination, + StatusLabel, + ColoredLabel, +} from "@/Components/design-elements"; +import { SPACING } from "@/Utils/Theme/constants"; +import type { Header } from "@/Components/design-elements/Table"; +import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu"; +import { ArrowUp, ArrowDown } from "lucide-react"; + +import { useTranslation } from "react-i18next"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { useTheme } from "@mui/material/styles"; +import { useNavigate } from "react-router-dom"; +import { usePost } from "@/Hooks/UseApi"; + +import type { Monitor } from "@/Types/Monitor"; +import type { Tag } from "@/Types/Tag"; +import type { DockerContainerSummary } from "@/Types/Check"; +import { Checkbox } from "@/Components/inputs"; + +const summaryOf = (monitor: Monitor): DockerContainerSummary | undefined => { + const recentChecks = monitor.recentChecks ?? []; + return recentChecks[recentChecks.length - 1]?.containerSummary; +}; + +interface DockerMonitorsTableProps { + monitors: Monitor[]; + tags: Tag[]; + refetch: () => void; + setSelectedMonitor: (monitor: Monitor | null) => void; + sortField: string; + setSortField: (field: string) => void; + sortOrder: "asc" | "desc"; + setSortOrder: (order: "asc" | "desc") => void; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; + selectedRows?: string[]; + onSelectionChange?: (selected: string[]) => void; +} + +export const DockerMonitorsTable = ({ + monitors, + tags, + refetch, + setSelectedMonitor, + sortField, + setSortField, + sortOrder, + setSortOrder, + count, + page, + setPage, + rowsPerPage, + setRowsPerPage, + selectedRows = [], + onSelectionChange, +}: DockerMonitorsTableProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const navigate = useNavigate(); + const { post } = usePost, Monitor>(); + + const selectedSet = new Set(selectedRows); + const isAllSelected = + monitors.length > 0 && monitors.every((m) => selectedSet.has(m.id)); + const isSomeSelected = selectedRows.length > 0 && !isAllSelected; + + const handleSelectAll = (checked: boolean) => { + onSelectionChange?.(checked ? monitors.map((m) => m.id) : []); + }; + + const handleSelectRow = (id: string, checked: boolean) => { + if (!onSelectionChange) return; + onSelectionChange( + checked ? [...selectedRows, id] : selectedRows.filter((rowId) => rowId !== id) + ); + }; + + const isRowSelected = (id: string) => selectedSet.has(id); + + const handlePageChange = ( + _e: React.MouseEvent | null, + newPage: number + ) => { + setPage(newPage); + }; + + const handleRowsPerPageChange = ( + e: React.ChangeEvent + ) => { + const value = Number(e.target.value); + setPage(0); + setRowsPerPage(value); + }; + + const handleSort = (e: React.MouseEvent, field: string) => { + e.preventDefault(); + e.stopPropagation(); + if (sortField === field) { + const newOrder = sortOrder === "asc" ? "desc" : "asc"; + setSortOrder(newOrder); + } else { + setSortField(field); + setSortOrder("asc"); + } + refetch(); + }; + + const getActions = (monitor: Monitor): ActionMenuItem[] => { + return [ + { + id: 2, + label: t("pages.common.monitors.actions.details"), + action: () => { + navigate(`${monitor.id}`); + }, + }, + { + id: 3, + label: t("pages.common.monitors.actions.incidents"), + action: () => { + navigate(`/incidents?monitorId=${monitor.id}`); + }, + }, + { + id: 4, + label: t("pages.common.monitors.actions.configure"), + action: () => { + navigate(`/docker/configure/${monitor.id}`); + }, + }, + { + id: 6, + label: + monitor.isActive === false + ? t("common.buttons.resume") + : t("common.buttons.pause"), + action: async () => { + await post(`/monitors/pause/${monitor.id}`, {}); + refetch(); + }, + closeMenu: true, + }, + { + id: 7, + label: ( + + {t("common.buttons.delete")} + + ), + action: () => { + setSelectedMonitor(monitor); + }, + closeMenu: true, + }, + ]; + }; + + const getHeaders = () => { + const renderSortIcon = (isActive: boolean) => ( + + {isActive ? ( + sortOrder === "asc" ? ( + + ) : ( + + ) + ) : null} + + ); + const headers: Header[] = [ + { + id: "selection", + content: ( + handleSelectAll(e.target.checked)} + /> + ), + hideMobileLabel: true, + render: (row) => ( + { + handleSelectRow(row.id, e.target.checked); + }} + onClick={(e) => e.stopPropagation()} + /> + ), + onClick: (e) => e?.stopPropagation(), + }, + { + id: "name", + align: "left", + mobileLabel: t("pages.docker.table.headers.host"), + content: ( + handleSort(e, "name")} + sx={{ cursor: "pointer" }} + > + {t("pages.docker.table.headers.host")} + {renderSortIcon(sortField === "name")} + + ), + render: (row) => { + return ( + + {row.name} + + {row.url} + + + ); + }, + }, + { + id: "status", + mobileLabel: t("common.table.headers.status"), + content: ( + handleSort(e, "status")} + sx={{ cursor: "pointer" }} + > + + {t("common.table.headers.status")} + {renderSortIcon(sortField === "status")} + + ), + render: (row) => { + return ; + }, + }, + { + id: "containers", + content: t("pages.docker.table.headers.containers"), + render: (row) => { + const summary = summaryOf(row); + return ( + + {summary + ? t("pages.docker.table.containersRunning", { + running: summary.running, + total: summary.total, + }) + : "—"} + + ); + }, + }, + { + id: "tags", + content: t("common.table.headers.tags"), + render: (row) => { + if (row.tags.length === 0) return "-"; + return ( + + {row.tags.map((tagId) => { + const fullTag = tags.find((t) => t.id === tagId); + if (!fullTag) return null; + return ( + + ); + })} + + ); + }, + }, + + { + id: "actions", + content: t("common.table.headers.actions"), + render: (row) => { + return ; + }, + }, + ]; + return headers; + }; + + let headers = getHeaders(); + + if (isSmall) { + headers = headers.filter((h) => h.id !== "histogram"); + } + return ( + + { + navigate(`/docker/${row.id}`); + }} + getRowSx={(row) => ({ + backgroundColor: isRowSelected(row.id) + ? theme.palette.action.selected + : "inherit", + })} + /> + + + ); +}; diff --git a/client/src/Pages/Docker/Monitors/index.tsx b/client/src/Pages/Docker/Monitors/index.tsx new file mode 100644 index 000000000..96d13abbf --- /dev/null +++ b/client/src/Pages/Docker/Monitors/index.tsx @@ -0,0 +1,44 @@ +import { MonitorListPage } from "@/Components/monitors"; +import { DockerMonitorsTable } from "@/Pages/Docker/Monitors/Components/DockerMonitorsTable"; +import { useMonitorListController } from "@/Hooks/useMonitorListController"; + +const DockerMonitorsPage = () => { + const c = useMonitorListController({ + types: ["docker"], + checksLimit: 25, + refreshInterval: 5000, + rowsPerPageTable: "docker", + rowsPerPageDefault: 10, + }); + + return ( + + + + ); +}; + +export default DockerMonitorsPage; diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx index 968b468c2..04eb68bdd 100644 --- a/client/src/Routes/index.tsx +++ b/client/src/Routes/index.tsx @@ -20,6 +20,10 @@ import PageSpeedDetails from "@/Pages/PageSpeed/Details/"; import Infrastructure from "@/Pages/Infrastructure/Monitors"; import InfrastructureDetails from "@/Pages/Infrastructure/Details"; +// Docker +import Docker from "@/Pages/Docker/Monitors"; +import DockerDetails from "@/Pages/Docker/Details"; + // Checks import Checks from "@/Pages/Checks"; @@ -139,6 +143,22 @@ const Routes = () => { path="infrastructure/:monitorId" element={} /> + } + /> + } + /> + } + /> + } + /> } diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index 78e970f04..f6f84102a 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -119,6 +119,14 @@ export interface CheckTimings { }; } +// Mirrors DockerContainerSummary in server/src/types/network.ts. +export interface DockerContainerSummary { + total: number; + running: number; + stopped: number; + unhealthy: number; +} + export interface Check { id: string; metadata: CheckMetadata; @@ -133,6 +141,7 @@ export interface Check { host?: CheckHostInfo; errors?: CheckErrorInfo[]; capture?: CheckCaptureInfo; + containerSummary?: DockerContainerSummary; net?: CheckNetworkInterfaceInfo[]; accessibility?: number; bestPractices?: number; @@ -287,6 +296,7 @@ export type CheckSnapshot = Pick< memory?: SnapshotMemoryInfo; disk?: SnapshotDiskInfo[]; host?: SnapshotHostInfo; + containerSummary?: DockerContainerSummary; }; export interface HasResponseTime { responseTime: number; diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index a5ed14c01..759c633ec 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -30,7 +30,6 @@ export type MonitorType = (typeof MonitorTypes)[number]; export const SelectableMonitorTypes = [ "http", "ping", - "docker", "port", "game", "grpc", diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index 401771f94..ebdd0e839 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -12,7 +12,7 @@ export const getMonitorPath = (type: MonitorType): string => { websocket: "uptime", dns: "uptime", unknown: "uptime", - docker: "uptime", + docker: "docker", hardware: "infrastructure", pagespeed: "pagespeed", }; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index e82f84949..735da51e2 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -203,6 +203,7 @@ "uptime": "Uptime", "pagespeed": "Pagespeed", "infrastructure": "Infrastructure", + "docker": "Docker", "notifications": "Notifications", "tags": "Tags", "proxies": "Proxies", @@ -918,6 +919,24 @@ "description": "When monitors go down or recover, incidents appear here with timing, monitor details and resolution history." } }, + "docker": { + "header": { + "title": "Docker monitors", + "description": "Monitor your Docker hosts and every container running on them." + }, + "fallback": { + "title": "No Docker monitors yet", + "description": "Add a Docker host to monitor its containers.", + "actionButton": "Create a monitor" + }, + "table": { + "headers": { + "host": "Host", + "containers": "Containers" + }, + "containersRunning": "{{running}} of {{total}} running" + } + }, "infrastructure": { "charts": { "labels": { From 9a6602b9e1b1a1a9b8ca38d613b7033c1fd5dd34 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Fri, 28 Aug 2026 19:13:33 -0400 Subject: [PATCH 023/137] fix(client): add hover states to tables, cards, stat boxes and icon buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clickable surfaces across the app set `cursor: pointer` but gave no visual response on hover, so rows and cards read as static text. Two separate causes: - `DataTable` never passed MUI's opt-in `hover` prop on `TableRow`, which is `false` by default, so no table in the app shaded on hover. One change here covers all 16 page tables (Uptime, Incidents, Tags, Team, Notifications, Maintenance, Proxies, Status pages, Checks, Geo checks, Infrastructure, PageSpeed and the three Logs tables). - `IconButton` gates its hover style behind `props => !props.disableRipple`, and the theme sets `disableRipple: true` globally, so the row action gear and the pagination arrows lost their background. `MenuItem` is unaffected — its hover sits on the base style — so action menu items already worked and are left alone. Adds a `HOVER.ROW` (0.025) constant and an `action.rowHover` palette token, defined in both light and dark palettes so the overlay direction is correct on each surface. Kept separate from MUI's shared `action.hover` so menu items, which read that token, are not restyled as a side effect. Also fixes `cursor` on expandable rows, which showed `default` despite being clickable to expand. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Components/actions-menu/index.tsx | 11 +++++++- .../Components/design-elements/StatBox.tsx | 7 ++++- .../src/Components/design-elements/Table.tsx | 28 +++++++++++++++++-- client/src/Utils/Theme/Palette.ts | 7 +++++ client/src/Utils/Theme/Theme.ts | 6 ++++ client/src/Utils/Theme/constants.ts | 1 + 6 files changed, 55 insertions(+), 5 deletions(-) diff --git a/client/src/Components/actions-menu/index.tsx b/client/src/Components/actions-menu/index.tsx index 3b2f3688a..726ef6821 100644 --- a/client/src/Components/actions-menu/index.tsx +++ b/client/src/Components/actions-menu/index.tsx @@ -3,6 +3,7 @@ import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import IconButton from "@mui/material/IconButton"; import { Settings } from "lucide-react"; +import { useTheme } from "@mui/material/styles"; export type ActionMenuItem = { id: number | string; @@ -12,6 +13,7 @@ export type ActionMenuItem = { }; export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => { + const theme = useTheme(); const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); @@ -27,7 +29,14 @@ export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => { return (
- + diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index a02f9be8f..e4e636f80 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -108,6 +108,11 @@ export function DataTable< borderRadius: theme.shape.borderRadius, padding: theme.spacing(LAYOUT.XS), cursor: onRowClick ? "pointer" : "default", + ...(onRowClick && { + "&:hover": { + backgroundColor: theme.palette.action.rowHover, + }, + }), }} key={key} > @@ -207,6 +212,10 @@ export function DataTable< "& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": { borderBottom: "none", }, + "& .MuiTableBody-root .MuiTableRow-root.is-clickable:hover .MuiTableCell-root": + { + backgroundColor: theme.palette.action.rowHover, + }, }} > @@ -231,8 +240,9 @@ export function DataTable< return ( { @@ -314,7 +324,13 @@ function TablePaginationActions(props: TablePaginationActionsProps) { return ( Date: Fri, 28 Aug 2026 19:19:00 -0400 Subject: [PATCH 024/137] feat(client): group sidebar navigation into four labelled sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar listed twelve items in a flat column ordered roughly by when each feature was added, so nothing signalled that Uptime, Pagespeed and Infrastructure are the same kind of thing, or that Tags and Proxies are configuration rather than views. Adding a new monitor type made this worse: the natural place for a new entry is the end of the list, three rows below the item it most resembles. Groups the menu into Monitoring, Alerting, Reporting and Configuration. `getMenu` now returns groups of items rather than a flat array; the sidebar renders an eyebrow label per group. When collapsed to 64px there is no room for a label, so the labels are replaced by dividers between groups — the grouping survives as rhythm. Settings moves from the middle of the list to the end of Configuration. Adds four `components.sidebar.menu.groups.*` keys across all 19 locales. Also corrects the i18n note in CLAUDE.md, which claimed translations are managed through PoEditor. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- CLAUDE.md | 2 +- client/src/Components/sidebar/Menu.tsx | 124 ++++++++++++++---------- client/src/Components/sidebar/index.tsx | 53 +++++++--- client/src/locales/ar.json | 8 +- client/src/locales/ca.json | 8 +- client/src/locales/cs.json | 8 +- client/src/locales/de.json | 8 +- client/src/locales/en.json | 8 +- client/src/locales/es.json | 8 +- client/src/locales/fi.json | 8 +- client/src/locales/fr.json | 8 +- client/src/locales/it.json | 8 +- client/src/locales/ja.json | 8 +- client/src/locales/pl.json | 8 +- client/src/locales/pt-BR.json | 8 +- client/src/locales/ru.json | 8 +- client/src/locales/th.json | 8 +- client/src/locales/tr.json | 8 +- client/src/locales/uk.json | 8 +- client/src/locales/vi.json | 8 +- client/src/locales/zh-CN.json | 8 +- client/src/locales/zh-TW.json | 8 +- 22 files changed, 248 insertions(+), 83 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3e601b453..498858e16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,7 +109,7 @@ client/src/ - **Data Fetching**: SWR + Axios - **Database**: MongoDB with Mongoose ODM - **Queue/Cache**: Redis + BullMQ + Pulse (cron scheduling) -- **i18n**: i18next + react-i18next (translations via PoEditor) +- **i18n**: i18next + react-i18next (non-English locales are machine translated, not managed through a translation service) --- diff --git a/client/src/Components/sidebar/Menu.tsx b/client/src/Components/sidebar/Menu.tsx index 93cd94623..e7c4b9581 100644 --- a/client/src/Components/sidebar/Menu.tsx +++ b/client/src/Components/sidebar/Menu.tsx @@ -23,64 +23,84 @@ import { export const getMenu = (t: Function) => { return [ { - name: t("components.sidebar.menu.uptime"), - path: "uptime", - icon: , + group: t("components.sidebar.menu.groups.monitoring"), + items: [ + { + name: t("components.sidebar.menu.uptime"), + path: "uptime", + icon: , + }, + { + name: t("components.sidebar.menu.pagespeed"), + path: "pagespeed", + icon: , + }, + { + name: t("components.sidebar.menu.infrastructure"), + path: "infrastructure", + icon: , + }, + ], }, { - name: t("components.sidebar.menu.pagespeed"), - path: "pagespeed", - icon: , + group: t("components.sidebar.menu.groups.alerting"), + items: [ + { + name: t("components.sidebar.menu.incidents"), + path: "incidents", + icon: , + }, + { + name: t("components.sidebar.menu.notifications"), + path: "notifications", + icon: , + }, + { + name: t("components.sidebar.menu.maintenance"), + path: "maintenance", + icon: , + }, + ], }, { - name: t("components.sidebar.menu.infrastructure"), - path: "infrastructure", - icon: , + group: t("components.sidebar.menu.groups.reporting"), + items: [ + { + name: t("components.sidebar.menu.statusPages"), + path: "status", + icon: , + }, + { + name: t("components.sidebar.menu.checks"), + path: "checks", + icon: , + }, + { + name: t("components.sidebar.menu.logs"), + path: "logs", + icon: , + }, + ], }, { - name: t("components.sidebar.menu.notifications"), - path: "notifications", - icon: , - }, - { - name: t("components.sidebar.menu.tags"), - path: "tags", - icon: , - }, - { - name: t("components.sidebar.menu.proxies"), - path: "proxies", - icon: , - }, - { - name: t("components.sidebar.menu.checks"), - path: "checks", - icon: , - }, - { - name: t("components.sidebar.menu.incidents"), - path: "incidents", - icon: , - }, - { - name: t("components.sidebar.menu.statusPages"), - path: "status", - icon: , - }, - { - name: t("components.sidebar.menu.maintenance"), - path: "maintenance", - icon: , - }, - { - name: t("components.sidebar.menu.logs"), - path: "logs", - icon: , - }, - { - name: t("components.sidebar.menu.settings"), - icon: , - path: "settings", + group: t("components.sidebar.menu.groups.configuration"), + items: [ + { + name: t("components.sidebar.menu.tags"), + path: "tags", + icon: , + }, + { + name: t("components.sidebar.menu.proxies"), + path: "proxies", + icon: , + }, + { + name: t("components.sidebar.menu.settings"), + path: "settings", + icon: , + }, + ], }, ]; }; diff --git a/client/src/Components/sidebar/index.tsx b/client/src/Components/sidebar/index.tsx index d92441b01..6c7bdfb78 100644 --- a/client/src/Components/sidebar/index.tsx +++ b/client/src/Components/sidebar/index.tsx @@ -1,6 +1,8 @@ import { useEffect } from "react"; import Backdrop from "@mui/material/Backdrop"; import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; import List from "@mui/material/List"; import Divider from "@mui/material/Divider"; import { LAYOUT } from "@/Utils/Theme/constants"; @@ -89,17 +91,46 @@ export const Sidebar = () => { pt={theme.spacing(LAYOUT.MD)} pb={theme.spacing(LAYOUT.MD)} /> - {menu.map((item) => { - const selected = location.pathname.startsWith(`/${item.path}`); - return ( - handleNavClick(item.path)} - /> - ); - })} + {menu.map((group, groupIndex) => ( + + {collapsed ? ( + groupIndex > 0 && ( + + ) + ) : ( + + {group.group} + + )} + {group.items.map((item) => { + const selected = location.pathname.startsWith(`/${item.path}`); + return ( + handleNavClick(item.path)} + /> + ); + })} + + ))} diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 55f34650d..44e849fbc 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -181,7 +181,13 @@ "statusPages": "صفحات الحالة", "maintenance": "الصيانة", "logs": "السجلات", - "settings": "الإعدادات" + "settings": "الإعدادات", + "groups": { + "monitoring": "المراقبة", + "alerting": "التنبيهات", + "reporting": "التقارير", + "configuration": "الإعدادات العامة" + } }, "bottomMenu": { "support": "الدعم", diff --git a/client/src/locales/ca.json b/client/src/locales/ca.json index 7e39eca86..541230c14 100644 --- a/client/src/locales/ca.json +++ b/client/src/locales/ca.json @@ -189,7 +189,13 @@ "statusPages": "Pàgines d'estat", "maintenance": "Manteniment", "logs": "Registres", - "settings": "Configuració" + "settings": "Configuració", + "groups": { + "monitoring": "Monitoratge", + "alerting": "Alertes", + "reporting": "Informes", + "configuration": "Configuració" + } }, "bottomMenu": { "support": "Suport", diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index d04b9b23b..7ca4d84e9 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -181,7 +181,13 @@ "statusPages": "Stavové stránky", "maintenance": "Údržba", "logs": "Záznamy", - "settings": "Nastavení" + "settings": "Nastavení", + "groups": { + "monitoring": "Monitorování", + "alerting": "Upozornění", + "reporting": "Přehledy", + "configuration": "Konfigurace" + } }, "bottomMenu": { "support": "Podpora", diff --git a/client/src/locales/de.json b/client/src/locales/de.json index d43d5d52a..2a8ff08f1 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -181,7 +181,13 @@ "statusPages": "Statusseite", "maintenance": "Wartung", "logs": "Protokolle", - "settings": "Einstellungen" + "settings": "Einstellungen", + "groups": { + "monitoring": "Überwachung", + "alerting": "Benachrichtigungen", + "reporting": "Berichte", + "configuration": "Konfiguration" + } }, "bottomMenu": { "support": "Support", diff --git a/client/src/locales/en.json b/client/src/locales/en.json index e82f84949..27efb63c8 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -211,7 +211,13 @@ "statusPages": "Status pages", "maintenance": "Maintenance", "logs": "Logs", - "settings": "Settings" + "settings": "Settings", + "groups": { + "monitoring": "Monitoring", + "alerting": "Alerting", + "reporting": "Reporting", + "configuration": "Configuration" + } }, "bottomMenu": { "support": "Support", diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 589399a83..154c9eb58 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -181,7 +181,13 @@ "statusPages": "Páginas de estado", "maintenance": "Mantenimiento", "logs": "Registros", - "settings": "Configuración" + "settings": "Configuración", + "groups": { + "monitoring": "Monitorización", + "alerting": "Alertas", + "reporting": "Informes", + "configuration": "Configuración" + } }, "bottomMenu": { "support": "Soporte", diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index 030698d44..a25f9211c 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -181,7 +181,13 @@ "statusPages": "Tilasivut", "maintenance": "Huolto", "logs": "Lokit", - "settings": "Asetukset" + "settings": "Asetukset", + "groups": { + "monitoring": "Valvonta", + "alerting": "Hälytykset", + "reporting": "Raportointi", + "configuration": "Määritykset" + } }, "bottomMenu": { "support": "Tuki", diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 1e0a6b096..fe967457c 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -181,7 +181,13 @@ "statusPages": "Pages de statut", "maintenance": "Maintenance", "logs": "Journaux", - "settings": "Paramètres" + "settings": "Paramètres", + "groups": { + "monitoring": "Surveillance", + "alerting": "Alertes", + "reporting": "Rapports", + "configuration": "Configuration" + } }, "bottomMenu": { "support": "Support", diff --git a/client/src/locales/it.json b/client/src/locales/it.json index 3d6d1ea16..4d931a759 100644 --- a/client/src/locales/it.json +++ b/client/src/locales/it.json @@ -193,7 +193,13 @@ "statusPages": "Pagine di stato", "maintenance": "Manutenzione", "logs": "Log", - "settings": "Impostazioni" + "settings": "Impostazioni", + "groups": { + "monitoring": "Monitoraggio", + "alerting": "Avvisi", + "reporting": "Report", + "configuration": "Configurazione" + } }, "bottomMenu": { "support": "Supporto", diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index 16e2ec641..215bffc65 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -181,7 +181,13 @@ "statusPages": "ステータスページ", "maintenance": "メンテナンス", "logs": "ログ", - "settings": "設定" + "settings": "設定", + "groups": { + "monitoring": "監視", + "alerting": "アラート", + "reporting": "レポート", + "configuration": "設定" + } }, "bottomMenu": { "support": "サポート", diff --git a/client/src/locales/pl.json b/client/src/locales/pl.json index 81509ee6f..617fac273 100644 --- a/client/src/locales/pl.json +++ b/client/src/locales/pl.json @@ -214,7 +214,13 @@ "settings": "Ustawienia", "statusPages": "Strony statusu", "tags": "Tagi", - "uptime": "Czas działania" + "uptime": "Czas działania", + "groups": { + "monitoring": "Monitorowanie", + "alerting": "Alerty", + "reporting": "Raporty", + "configuration": "Konfiguracja" + } }, "starPrompt": { "description": "Zobacz najnowsze wydania i pomóż rozwijać społeczność na GitHub", diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index c75ead3ef..7650f34b0 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -181,7 +181,13 @@ "statusPages": "Pagina de status", "maintenance": "Manutenção", "logs": "Registros", - "settings": "Configurações" + "settings": "Configurações", + "groups": { + "monitoring": "Monitoramento", + "alerting": "Alertas", + "reporting": "Relatórios", + "configuration": "Configuração" + } }, "bottomMenu": { "support": "Suporte", diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index cf5e486f7..fca859a26 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -181,7 +181,13 @@ "statusPages": "Страницы статуса", "maintenance": "Обслуживание", "logs": "Логи", - "settings": "Настройки" + "settings": "Настройки", + "groups": { + "monitoring": "Мониторинг", + "alerting": "Оповещения", + "reporting": "Отчёты", + "configuration": "Конфигурация" + } }, "bottomMenu": { "support": "Поддержка", diff --git a/client/src/locales/th.json b/client/src/locales/th.json index 66d354adc..071c8b12e 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -181,7 +181,13 @@ "statusPages": "หน้าสถานะ", "maintenance": "การบำรุงรักษา", "logs": "บันทึก", - "settings": "การตั้งค่า" + "settings": "การตั้งค่า", + "groups": { + "monitoring": "การตรวจสอบ", + "alerting": "การแจ้งเตือน", + "reporting": "รายงาน", + "configuration": "การกำหนดค่า" + } }, "bottomMenu": { "support": "การสนับสนุน", diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index 26f8aace0..10a855ba2 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -181,7 +181,13 @@ "statusPages": "Durum sayfaları", "maintenance": "Bakım", "logs": "Günlükler", - "settings": "Ayarlar" + "settings": "Ayarlar", + "groups": { + "monitoring": "İzleme", + "alerting": "Uyarılar", + "reporting": "Raporlama", + "configuration": "Yapılandırma" + } }, "bottomMenu": { "support": "Destek", diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index d1cb983d1..99c8b9e9d 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -181,7 +181,13 @@ "statusPages": "Сторінки статусу", "maintenance": "Обслуговування", "logs": "Журнали", - "settings": "Налаштування" + "settings": "Налаштування", + "groups": { + "monitoring": "Моніторинг", + "alerting": "Сповіщення", + "reporting": "Звіти", + "configuration": "Конфігурація" + } }, "bottomMenu": { "support": "Підтримка", diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index 81da1c6fc..0f2903793 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -181,7 +181,13 @@ "statusPages": "Trang trạng thái", "maintenance": "Bảo trì", "logs": "Nhật ký", - "settings": "Cài đặt" + "settings": "Cài đặt", + "groups": { + "monitoring": "Giám sát", + "alerting": "Cảnh báo", + "reporting": "Báo cáo", + "configuration": "Cấu hình" + } }, "bottomMenu": { "support": "Hỗ trợ", diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index 1a455d41c..e9b0d251c 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -181,7 +181,13 @@ "statusPages": "状态页", "maintenance": "维护", "logs": "日志", - "settings": "设置" + "settings": "设置", + "groups": { + "monitoring": "监控", + "alerting": "告警", + "reporting": "报表", + "configuration": "配置" + } }, "bottomMenu": { "support": "支持", diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index a15a2a2bb..102082d49 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -181,7 +181,13 @@ "statusPages": "狀態頁面", "maintenance": "維護", "logs": "日誌", - "settings": "設定" + "settings": "設定", + "groups": { + "monitoring": "監控", + "alerting": "警示", + "reporting": "報表", + "configuration": "設定" + } }, "bottomMenu": { "support": "支援", From 30881a0af1c9d4e7817a0b18bb5b60b7e6c08682 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Fri, 28 Aug 2026 19:28:29 -0400 Subject: [PATCH 025/137] fix(client): drop invalid li wrapper from sidebar groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group wrapper was rendering as `
  • `, but the enclosing `List` is `component="nav"` and so renders a `
  • { + navigate(`/docker/container/${row.id}`); + }} + // getRowSx={(row) => ({ + // backgroundColor: isRowSelected(row.id) + // ? theme.palette.action.selected + // : "inherit", + // })} + /> + + ); +}; diff --git a/client/src/Pages/Docker/HostDetails/Components/StatBoxes.tsx b/client/src/Pages/Docker/HostDetails/Components/StatBoxes.tsx new file mode 100644 index 000000000..4116115ae --- /dev/null +++ b/client/src/Pages/Docker/HostDetails/Components/StatBoxes.tsx @@ -0,0 +1,48 @@ +import Stack from "@mui/material/Stack"; +import { StatBox } from "@/Components/design-elements"; + +// Types +import type { DockerStats } from "@/Types/Monitor"; + +// Hooks +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; + +// Utils +import prettyBytes from "pretty-bytes"; + +export const DockerStatusBoxes = ({ stats }: { stats: DockerStats | undefined }) => { + const { t } = useTranslation(); + const theme = useTheme(); + + if (!stats) return null; + + const { total, running, stopped, unhealthy } = stats.latest?.summary ?? {}; + + return ( + + + + + + + + + ); +}; diff --git a/client/src/Pages/Docker/HostDetails/index.tsx b/client/src/Pages/Docker/HostDetails/index.tsx new file mode 100644 index 000000000..4cd2d3903 --- /dev/null +++ b/client/src/Pages/Docker/HostDetails/index.tsx @@ -0,0 +1,52 @@ +import { BasePage } from "@/Components/design-elements"; +import { HeaderMonitorControls, MonitorStatBoxes } from "@/Components/monitors"; +import { DockerStatusBoxes } from "@/Pages/Docker/HostDetails/Components/StatBoxes"; + +// Types +import type { DockerDetailsResponse } from "@/Types/Monitor"; + +// Hooks +import { useGet } from "@/Hooks/UseApi"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; +import { useParams } from "react-router-dom"; +import { DockerContainersTable } from "@/Pages/Docker/HostDetails/Components/DockerContainersTable"; + +const DockerHostDetailsPage = () => { + const { monitorId } = useParams<{ monitorId: string }>(); + const isAdmin = useIsAdmin(); + + // The containers list renders the latest check only, so no date range is + // offered; the endpoint's dateRange param only shapes the aggregate buckets. + const monitorDetailsUrl = monitorId ? `/monitors/docker/details/${monitorId}` : null; + + const { data: monitorDetailsData, refetch: refetchMonitor } = + useGet( + monitorDetailsUrl, + {}, + { refreshInterval: 10000, keepPreviousData: true } + ); + + const monitor = monitorDetailsData?.monitor; + const monitorStats = monitorDetailsData?.monitorStats ?? null; + const stats = monitorDetailsData?.stats; + const containers = stats?.latest?.containers ?? []; + + return ( + + + + + + + ); +}; + +export default DockerHostDetailsPage; diff --git a/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx b/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx index 33fc65706..7b35e6bfe 100644 --- a/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx +++ b/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx @@ -325,7 +325,7 @@ export const DockerMonitorsTable = ({ headers={headers} data={monitors} onRowClick={(row) => { - navigate(`/docker/${row.id}`); + navigate(`/docker/host/${row.id}`); }} getRowSx={(row) => ({ backgroundColor: isRowSelected(row.id) diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx index 04eb68bdd..b073c08bf 100644 --- a/client/src/Routes/index.tsx +++ b/client/src/Routes/index.tsx @@ -22,7 +22,8 @@ import InfrastructureDetails from "@/Pages/Infrastructure/Details"; // Docker import Docker from "@/Pages/Docker/Monitors"; -import DockerDetails from "@/Pages/Docker/Details"; +import DockerHostDetails from "@/Pages/Docker/HostDetails"; +import DockerDetails from "../../.."; // Checks import Checks from "@/Pages/Checks"; @@ -159,6 +160,10 @@ const Routes = () => { path="docker/:monitorId" element={} /> + } + /> } diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index f6f84102a..6e5ca29f8 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -127,6 +127,38 @@ export interface DockerContainerSummary { unhealthy: number; } +// Mirrors DockerContainerStates in server/src/types/network.ts. +export const DockerContainerStates = [ + "created", + "running", + "paused", + "restarting", + "removing", + "exited", + "dead", +] as const; +export type DockerContainerState = (typeof DockerContainerStates)[number]; + +// Mirrors DockerHealthStatuses in server/src/types/network.ts. +export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const; +export type DockerHealthStatus = (typeof DockerHealthStatuses)[number]; + +// Mirrors DockerContainerInfo in server/src/types/network.ts. +export interface DockerContainerInfo { + id: string; + name: string; + image: string; + state: DockerContainerState; + status: string; + health: DockerHealthStatus; + cpuPct?: number; // fraction of one core; exceeds 1 when using multiple cores + memoryUsedBytes?: number; + memoryLimitBytes?: number; + memoryPct?: number; // 0-1 fraction of the memory limit + restartCount?: number; + startedAt?: string; +} + export interface Check { id: string; metadata: CheckMetadata; diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 759c633ec..8b83a5a72 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -3,6 +3,8 @@ import type { CheckSnapshot, GroupedUptimeCheck, DailyCheckBucket, + DockerContainerInfo, + DockerContainerSummary, } from "@/Types/Check"; import type { PageSpeedGroupedCheck } from "@/Types/Check"; import type { GeoContinent } from "@/Types/GeoCheck"; @@ -268,6 +270,41 @@ export interface HardwareDetailsResponse { monitorStats: MonitorStats | null; } +// Mirrors DockerStatsBucket in server/src/domain/checks/check.type.ts. +export interface DockerStatsBucket { + _id: string; + avgResponseTime: number | null; + upCount: number; + totalCount: number; + avgRunning: number | null; + avgTotal: number | null; + avgUnhealthy: number | null; +} + +// Mirrors DockerStats in server/src/domain/checks/check.type.ts. +export interface DockerStats { + aggregateData: { + totalChecks: number; + }; + upChecks: { + totalChecks: number; + }; + aggregate: DockerStatsBucket[]; + latest: { + containers: DockerContainerInfo[]; + summary?: DockerContainerSummary; + checkedAt: string; + } | null; +} + +// Response of GET /monitors/docker/details/{monitorId}; mirrors +// dockerDetailsResponseSchema in server/src/api/validation/monitorValidation.ts. +export interface DockerDetailsResponse { + monitor: Monitor; + stats: DockerStats; + monitorStats: MonitorStats | null; +} + export interface Game { name: string; options?: { diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index ebdd0e839..638b5539a 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -1,6 +1,8 @@ import type { MonitorStatus, MonitorType } from "@/Types/Monitor"; import type { PaletteKey } from "@/Utils/Theme/Theme"; import type { ValueType } from "@/Components/design-elements/StatusLabel"; +import type { DockerContainerState } from "@/Types/Check"; +import type { Doc } from "zod/v4/core"; export const getMonitorPath = (type: MonitorType): string => { const pathMap: Record = { @@ -41,6 +43,19 @@ export const getValuePalette = (value: ValueType): PaletteKey => { return paletteMap[value]; }; +export const getDockerStatePalette = (state: DockerContainerState): PaletteKey => { + const paletteMap: Record = { + paused: "warning", + created: "warning", + running: "success", + restarting: "warning", + removing: "warning", + exited: "error", + dead: "error", + }; + return paletteMap[state]; +}; + export const getStatusColor = (status: MonitorStatus, theme: any): string => { if (status === "up") { return theme.palette.success.light; diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index a24ad2c18..152729f10 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -100,7 +100,7 @@ export class DockerProvider implements IStatusProvider { const systemDelta = stats.cpu_stats.system_cpu_usage - (stats.precpu_stats.system_cpu_usage ?? 0); const onlineCpus = stats.cpu_stats.online_cpus ?? stats.cpu_stats.cpu_usage.percpu_usage?.length ?? 1; if (!(systemDelta > 0) || cpuDelta < 0) return 0; - return (cpuDelta / systemDelta) * onlineCpus * 100; + return (cpuDelta / systemDelta) * onlineCpus; } private computeMemory(stats: Dockerode.ContainerStats): Pick { @@ -111,7 +111,7 @@ export class DockerProvider implements IStatusProvider { return { memoryUsedBytes, memoryLimitBytes, - memoryPct: memoryLimitBytes > 0 ? (memoryUsedBytes / memoryLimitBytes) * 100 : 0, + memoryPct: memoryLimitBytes > 0 ? memoryUsedBytes / memoryLimitBytes : 0, }; } diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 62f359b46..25ee220ac 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -117,10 +117,10 @@ export interface DockerContainerInfo { state: DockerContainerState; status: string; health: DockerHealthStatus; - cpuPct?: number; + cpuPct?: number; // fraction of one core (docker stats convention / 100); exceeds 1 when using multiple cores memoryUsedBytes?: number; memoryLimitBytes?: number; - memoryPct?: number; + memoryPct?: number; // 0-1 fraction of the memory limit restartCount?: number; startedAt?: string; // ISO date } diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts index 4aa1431dc..74ca2d8db 100644 --- a/server/test/unit/providers/network/dockerProvider.test.ts +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -324,10 +324,10 @@ describe("DockerProvider", () => { const result = await provider.handle(makeMonitor()); const container = result.payload?.containers[0]; - expect(container?.cpuPct).toBeCloseTo(80); + expect(container?.cpuPct).toBeCloseTo(0.8); expect(container?.memoryUsedBytes).toBe(200 * 1024 * 1024); expect(container?.memoryLimitBytes).toBe(1024 * 1024 * 1024); - expect(container?.memoryPct).toBeCloseTo(19.53125); + expect(container?.memoryPct).toBeCloseTo(0.1953125); }); it("falls back to the cgroup v1 cache field for page cache", async () => { @@ -351,7 +351,7 @@ describe("DockerProvider", () => { const result = await provider.handle(makeMonitor()); - expect(result.payload?.containers[0]?.cpuPct).toBeCloseTo(40); + expect(result.payload?.containers[0]?.cpuPct).toBeCloseTo(0.4); }); it("returns 0 cpu when the system delta is not positive", async () => { From fc9d2fe69da05cc7efac92b2fbbab3be54f4ffd7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 31 Aug 2026 12:04:20 -0700 Subject: [PATCH 043/137] fix docker details import path --- client/src/Pages/Docker/Details/index.tsx | 5 +++++ client/src/Routes/index.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 client/src/Pages/Docker/Details/index.tsx diff --git a/client/src/Pages/Docker/Details/index.tsx b/client/src/Pages/Docker/Details/index.tsx new file mode 100644 index 000000000..5937e113e --- /dev/null +++ b/client/src/Pages/Docker/Details/index.tsx @@ -0,0 +1,5 @@ +const DockerDetailsPage = () => { + return null; +}; + +export default DockerDetailsPage; diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx index b073c08bf..e7735fcd0 100644 --- a/client/src/Routes/index.tsx +++ b/client/src/Routes/index.tsx @@ -23,7 +23,7 @@ import InfrastructureDetails from "@/Pages/Infrastructure/Details"; // Docker import Docker from "@/Pages/Docker/Monitors"; import DockerHostDetails from "@/Pages/Docker/HostDetails"; -import DockerDetails from "../../.."; +import DockerDetails from "@/Pages/Docker/Details"; // Checks import Checks from "@/Pages/Checks"; From f10fb452bac63599809bc26317c663cf8ba140d2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Mon, 31 Aug 2026 12:11:09 -0700 Subject: [PATCH 044/137] extract strings --- .../Components/DockerContainersTable.tsx | 12 ++++++------ .../Docker/HostDetails/Components/StatBoxes.tsx | 9 ++++----- client/src/Utils/MonitorUtils.ts | 1 - client/src/locales/en.json | 17 +++++++++++++++++ 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx b/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx index 66ccb81e7..bf5bdc216 100644 --- a/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx +++ b/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx @@ -44,7 +44,7 @@ export const DockerContainersTable = ({ containers }: DockerContainersTableProps const headers: Header[] = [ { id: "container", - content: "Containers", + content: t("pages.docker.host.table.headers.container"), render: (row) => { return ( { return {row.state}; }, }, { id: "health", - content: "Health", + content: t("pages.docker.host.table.headers.health"), render: (row) => { return {row.health}; }, }, { id: "cpu", - content: "CPU", + content: t("pages.docker.host.table.headers.cpu"), render: (row) => { return {formatPercentage(row.cpuPct ?? 0)}; }, }, { id: "memory", - content: "Memory", + content: t("pages.docker.host.table.headers.memory"), render: (row) => { return {formatPercentage(row.memoryPct ?? 0)}; }, }, { id: "restarts", - content: "Restarts", + content: t("pages.docker.host.table.headers.restarts"), render: (row) => { return {row.restartCount}; }, diff --git a/client/src/Pages/Docker/HostDetails/Components/StatBoxes.tsx b/client/src/Pages/Docker/HostDetails/Components/StatBoxes.tsx index 4116115ae..1f65836b0 100644 --- a/client/src/Pages/Docker/HostDetails/Components/StatBoxes.tsx +++ b/client/src/Pages/Docker/HostDetails/Components/StatBoxes.tsx @@ -9,7 +9,6 @@ import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; // Utils -import prettyBytes from "pretty-bytes"; export const DockerStatusBoxes = ({ stats }: { stats: DockerStats | undefined }) => { const { t } = useTranslation(); @@ -26,21 +25,21 @@ export const DockerStatusBoxes = ({ stats }: { stats: DockerStats | undefined }) flexWrap={"wrap"} > diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index 638b5539a..ef48c2223 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -2,7 +2,6 @@ import type { MonitorStatus, MonitorType } from "@/Types/Monitor"; import type { PaletteKey } from "@/Utils/Theme/Theme"; import type { ValueType } from "@/Components/design-elements/StatusLabel"; import type { DockerContainerState } from "@/Types/Check"; -import type { Doc } from "zod/v4/core"; export const getMonitorPath = (type: MonitorType): string => { const pathMap: Record = { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 777a89c7a..2e6cdb2bc 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -941,6 +941,23 @@ "containers": "Containers" }, "containersRunning": "{{running}} of {{total}} running" + }, + "host": { + "statBoxes": { + "containers": "Containers", + "running": "Running", + "stopped": "Stopped", + "unhealthy": "Unhealthy" + }, + "table": { + "headers": { + "container": "Containers", + "health": "Health", + "cpu": "CPU", + "memory": "Memory", + "restarts": "Restarts" + } + } } }, "infrastructure": { From 8ccc7cbe9a94f54ec0392b27ef240cf1cddbc82d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 1 Sep 2026 10:05:34 -0700 Subject: [PATCH 045/137] new container endpoint,service, repo methods --- .../src/api/controllers/monitorController.ts | 25 ++++++++ server/src/api/routes/monitorRoutes.ts | 1 + .../src/api/validation/monitorValidation.ts | 6 ++ .../domain/checks/check.docker.aggregation.ts | 42 +++++++++++- .../checks/check.repository.interface.ts | 2 + .../domain/checks/check.repository.mongo.ts | 34 +++++++++- server/src/domain/checks/check.type.ts | 18 ++++++ server/src/domain/monitors/monitor.service.ts | 64 ++++++++++++++++++- server/src/domain/monitors/monitor.type.ts | 7 +- 9 files changed, 195 insertions(+), 4 deletions(-) diff --git a/server/src/api/controllers/monitorController.ts b/server/src/api/controllers/monitorController.ts index d636830e2..af73d786a 100644 --- a/server/src/api/controllers/monitorController.ts +++ b/server/src/api/controllers/monitorController.ts @@ -19,6 +19,8 @@ import { bulkPauseMonitorBodyValidation, getDockerDetailsByIdParamValidation, getDockerDetailsByIdQueryValidation, + getDockerContainerNameParamValidation, + getDockerContainerByNameQueryValidation, } from "@/api/validation/monitorValidation.js"; import sslChecker from "ssl-checker"; import * as whoiser from "whoiser"; @@ -34,6 +36,7 @@ export interface IMonitorController { getHardwareDetailsById: RequestHandler; getPageSpeedDetailsById: RequestHandler; getDockerDetailsById: RequestHandler; + getDockerContainerByName: RequestHandler; getGeoChecksByMonitorId: RequestHandler; getMonitorById: RequestHandler; createMonitor: RequestHandler; @@ -173,6 +176,28 @@ class MonitorController implements IMonitorController { }); }); + getDockerContainerByName = catchAsync(async (req: Request, res: Response) => { + const validatedParams = getDockerContainerNameParamValidation.parse(req.params); + const validatedQuery = getDockerContainerByNameQueryValidation.parse(req.query); + + const { monitorId, containerName } = validatedParams; + const dateRange = validatedQuery.dateRange || "recent"; + const teamId = requireTeamId(req.user?.teamId); + + const data = await this.monitorService.getDockerContainerByName({ + teamId, + monitorId, + containerName, + dateRange, + }); + + return res.status(200).json({ + success: true, + msg: "Docker container 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 6da47f2a7..5192f6619 100644 --- a/server/src/api/routes/monitorRoutes.ts +++ b/server/src/api/routes/monitorRoutes.ts @@ -20,6 +20,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); // 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 48477f731..aabf6a957 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -326,6 +326,12 @@ export const getHardwareDetailsByIdQueryValidation = z.object({ export const getDockerDetailsByIdParamValidation = z.object({ monitorId: z.string().min(1, "Monitor ID is required") }); export const getDockerDetailsByIdQueryValidation = z.object({ dateRange: z.enum(DateRanges).optional() }); +export const getDockerContainerNameParamValidation = z.object({ + monitorId: z.string().min(1, "Monitor ID is required"), + containerName: z.string().min(1, "Container name is required"), +}); +export const getDockerContainerByNameQueryValidation = z.object({ dateRange: z.enum(DateRanges).optional() }); + // Canonical monitor shape returned by /monitors endpoints. Keep aligned with // what the controllers actually serialize. export const monitorResponseSchema = z diff --git a/server/src/domain/checks/check.docker.aggregation.ts b/server/src/domain/checks/check.docker.aggregation.ts index 9edd1dcbb..37d6d8e87 100644 --- a/server/src/domain/checks/check.docker.aggregation.ts +++ b/server/src/domain/checks/check.docker.aggregation.ts @@ -1,5 +1,5 @@ import CheckModel from "@/domain/checks/check.model.js"; -import { DockerStatsBucket } from "@/domain/checks/check.type.js"; +import { DockerContainerStatsBucket, DockerStatsBucket } from "@/domain/checks/check.type.js"; import mongoose from "mongoose"; type DateRange = { start: Date; end: Date }; @@ -52,3 +52,43 @@ export const getDockerLatestCheck = async (monitorId: string) => .sort({ createdAt: -1 }) .select("containers containerSummary createdAt") .lean(); + +export const getDockerContainerStats = async ( + monitorId: string, + containerName: string, + dates: DateRange, + dateString: string +): Promise => + CheckModel.aggregate([ + { + $match: { + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + "metadata.type": "docker", + createdAt: { $gte: dates.start, $lte: dates.end }, + "containers.name": containerName, + }, + }, + { $unwind: "$containers" }, + { $match: { "containers.name": containerName } }, + { + $group: { + _id: { $dateToString: { format: dateString, date: "$createdAt" } }, + avgCpuPct: { $avg: "$containers.cpuPct" }, + avgMemoryUsedBytes: { $avg: "$containers.memoryUsedBytes" }, + avgMemoryPct: { $avg: "$containers.memoryPct" }, + minRestartCount: { $min: "$containers.restartCount" }, + maxRestartCount: { $max: "$containers.restartCount" }, + }, + }, + { $sort: { _id: 1 } }, + ]); + +export const getDockerContainerLatestCheck = async (monitorId: string, containerName: string) => + CheckModel.findOne({ + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + "metadata.type": "docker", + "containers.name": containerName, + }) + .sort({ createdAt: -1 }) + .select("containers createdAt") + .lean(); diff --git a/server/src/domain/checks/check.repository.interface.ts b/server/src/domain/checks/check.repository.interface.ts index 298981216..c48c126d1 100644 --- a/server/src/domain/checks/check.repository.interface.ts +++ b/server/src/domain/checks/check.repository.interface.ts @@ -4,6 +4,7 @@ import type { ChecksSummary, DailyCheckBucket, DockerChecksResult, + DockerContainerStats, HardwareChecksResult, PageSpeedChecksResult, UptimeChecksResult, @@ -40,6 +41,7 @@ export interface IChecksRepository { dateRange: DateRange, options?: { type?: MonitorType } ): Promise; + findDockerContainerChecks(monitorId: string, containerName: string, dateRange: DateRange): Promise>; findSummaryByTeamId(teamId: string, dateRange: DateRange): Promise; findUnevaluatedByMonitorId(monitorId: string, since: number): Promise; getDailyStatusBuckets(monitorIds: string[], days: number, timezone: string): Promise; diff --git a/server/src/domain/checks/check.repository.mongo.ts b/server/src/domain/checks/check.repository.mongo.ts index b779a2295..23106c0b7 100644 --- a/server/src/domain/checks/check.repository.mongo.ts +++ b/server/src/domain/checks/check.repository.mongo.ts @@ -10,6 +10,7 @@ import type { CheckMemoryInfo, CheckMetadata, CheckNetworkInterfaceInfo, + DockerContainerStats, GotTimings, HardwareCheckStats, } from "@/domain/checks/check.type.js"; @@ -24,7 +25,14 @@ import { getHardwareUpChecks, getHardwareStats, getHardwareTotalChecks } from "@ import { CheckFilter, DateRange } from "@/types/query.js"; import { AppError } from "@/utils/AppError.js"; import { NETWORK_ERROR } from "@/types/network.js"; -import { getDockerLatestCheck, getDockerStats, getDockerTotalChecks, getDockerUpChecks } from "@/domain/checks/check.docker.aggregation.js"; +import { + getDockerContainerLatestCheck, + getDockerContainerStats, + getDockerLatestCheck, + getDockerStats, + getDockerTotalChecks, + getDockerUpChecks, +} from "@/domain/checks/check.docker.aggregation.js"; const SERVICE_NAME = "ChecksRepository"; @@ -311,6 +319,30 @@ class MongoChecksRepository implements IChecksRepository { return this.findUptimeDateRangeChecks(options?.type ?? "http", monitorObjectId, start, end, dateString); }; + findDockerContainerChecks = async ( + monitorId: string, + containerName: string, + dateRange: DateRange + ): Promise> => { + const dates = { start: getDateForRange(dateRange), end: new Date() }; + const dateString = getDateFormat(dateRange); + const [aggregate, latestDoc] = await Promise.all([ + getDockerContainerStats(monitorId, containerName, dates, dateString), + getDockerContainerLatestCheck(monitorId, containerName), + ]); + const latestContainer = latestDoc?.containers?.find((c) => c.name === containerName); + return { + aggregate, + latest: + latestDoc && latestContainer + ? { + container: latestContainer, + checkedAt: toDateString(latestDoc.createdAt), + } + : null, + }; + }; + findSummaryByTeamId = async (teamId: string, dateRange: DateRange) => { const baseMatch = { "metadata.teamId": new mongoose.Types.ObjectId(teamId), diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index 00ab3641d..feeb4439a 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -281,3 +281,21 @@ export interface DockerStats { export interface DockerChecksResult extends DockerStats { monitorType: "docker"; } + +export type DockerContainerStatsBucket = { + _id: string; + avgCpuPct: number | null; + avgMemoryUsedBytes: number | null; + avgMemoryPct: number | null; + minRestartCount: number | null; + maxRestartCount: number | null; +}; + +export interface DockerContainerStats { + aggregate: DockerContainerStatsBucket[]; + restartsInRange: number; + latest: { + container: DockerContainerInfo; + checkedAt: string; + } | null; +} diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index 14b488af2..b4a7131df 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -8,9 +8,16 @@ import type { GamesMap, GroupedGeoCheckResult, DockerDetailsResult, + DockerContainerDetailsResult, } from "@/domain/monitors/monitor.type.js"; import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.type.js"; -import type { UptimeChecksResult, HardwareChecksResult, PageSpeedChecksResult, DockerChecksResult } from "@/domain/checks/check.type.js"; +import type { + UptimeChecksResult, + HardwareChecksResult, + PageSpeedChecksResult, + DockerChecksResult, + DockerContainerStatsBucket, +} from "@/domain/checks/check.type.js"; import type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js"; import type { IChecksRepository } from "@/domain/checks/check.repository.interface.js"; import type { IGeoChecksRepository } from "@/domain/geo-checks/geo-check.repository.interface.js"; @@ -31,6 +38,24 @@ const isUptimeChecksResult = ( result: UptimeChecksResult | HardwareChecksResult | PageSpeedChecksResult | DockerChecksResult ): result is UptimeChecksResult => supportsUptimeDetails(result.monitorType); +// Docker's RestartCount is cumulative and resets to 0 when a container is recreated, so restarts in the +// date range are recovered from bucket min/max deltas; a drop between buckets means a recreate. +const computeRestartsInRange = (aggregate: DockerContainerStatsBucket[]): number => { + let restarts = 0; + let prevMax: number | null = null; + for (const bucket of aggregate) { + const { minRestartCount: min, maxRestartCount: max } = bucket; + if (min === null || max === null) continue; + restarts += Math.max(0, max - min); + if (prevMax !== null) { + const delta = min - prevMax; + restarts += delta >= 0 ? delta : min + 1; + } + prevMax = max; + } + return restarts; +}; + export interface IMonitorService { // create createMonitor(teamId: string, userId: string, body: Partial): Promise; @@ -42,6 +67,12 @@ export interface IMonitorService { getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; getDockerDetailsById(args: { teamId: string; monitorId: string; dateRange: DateRange }): Promise; + getDockerContainerByName(args: { + teamId: string; + monitorId: string; + containerName: string; + dateRange: DateRange; + }): Promise; getGeoChecksByMonitorId(args: { teamId: string; monitorId: string; @@ -323,6 +354,37 @@ export class MonitorService implements IMonitorService { }; }; + getDockerContainerByName = async ({ + teamId, + monitorId, + containerName, + dateRange, + }: { + teamId: string; + monitorId: string; + containerName: string; + dateRange: DateRange; + }): 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 details`, status: 400 }); + } + + const { aggregate, latest } = await this.checksRepository.findDockerContainerChecks(monitor.id, containerName, dateRange); + + return { + monitor, + stats: { + aggregate, + latest, + restartsInRange: computeRestartsInRange(aggregate), + }, + }; + }; + getGeoChecksByMonitorId = async ({ teamId, monitorId, diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index e33f89c5c..df9581131 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -1,4 +1,4 @@ -import type { CheckSnapshot, DockerStats } from "@/domain/checks/check.type.js"; +import type { CheckSnapshot, DockerContainerStats, DockerStats } from "@/domain/checks/check.type.js"; export type { CheckSnapshot } from "@/domain/checks/check.type.js"; import type { GeoContinent, GroupedGeoCheck } from "@/domain/geo-checks/geo-check.type.js"; export type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js"; @@ -160,6 +160,11 @@ export interface DockerDetailsResult { monitorStats: MonitorStats | null; } +export interface DockerContainerDetailsResult { + monitor: Monitor; + stats: DockerContainerStats; +} + export interface PageSpeedDetailsResult { monitorData: { monitor: Monitor; From f85ed902e40ab199555bd52ff9f912155a721e50 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 1 Sep 2026 10:09:37 -0700 Subject: [PATCH 046/137] openapi docs, tests --- server/openapi.json | 419 ++++++++++++++++++ server/openapi/routes/monitor.ts | 13 + .../src/api/validation/monitorValidation.ts | 26 ++ .../test/unit/services/monitorService.test.ts | 108 +++++ 4 files changed, 566 insertions(+) diff --git a/server/openapi.json b/server/openapi.json index de869c314..cd6a3466d 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -7467,6 +7467,425 @@ } } }, + "/monitors/docker/details/{monitorId}/containers/{containerName}": { + "get": { + "tags": ["monitors"], + "summary": "Get details for one container on a Docker host monitor", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "monitorId", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "containerName", + "in": "path" + }, + { + "schema": { + "type": "string", + "enum": ["recent", "hour", "day", "week", "month", "all"] + }, + "required": false, + "name": "dateRange", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "monitor": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "dns", "unknown"] + }, + "url": { + "type": "string" + }, + "port": { + "type": "number" + }, + "isActive": { + "type": "boolean" + }, + "interval": { + "type": "number" + }, + "status": { + "type": "string", + "enum": ["up", "down", "paused", "initializing", "maintenance", "breached"] + }, + "statusWindowSize": { + "type": "number" + }, + "statusWindowThreshold": { + "type": "number" + }, + "ignoreTlsErrors": { + "type": "boolean" + }, + "proxyMode": { + "type": "string", + "enum": ["inherit", "none", "custom"] + }, + "proxyId": { + "type": "string" + }, + "useAdvancedMatching": { + "type": "boolean" + }, + "jsonPath": { + "type": "string" + }, + "expectedValue": { + "type": "string" + }, + "matchMethod": { + "type": "string", + "enum": ["equal", "include", "regex"] + }, + "method": { + "type": "string", + "enum": ["GET", "HEAD"] + }, + "notifications": { + "type": "array", + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "customUpCodes": { + "type": "array", + "items": { + "type": "number" + } + }, + "secret": { + "type": "string" + }, + "sshPrivateKey": { + "type": "string" + }, + "cpuAlertThreshold": { + "type": "number" + }, + "memoryAlertThreshold": { + "type": "number" + }, + "diskAlertThreshold": { + "type": "number" + }, + "tempAlertThreshold": { + "type": "number" + }, + "selectedDisks": { + "type": "array", + "items": { + "type": "string" + } + }, + "gameId": { + "type": "string" + }, + "grpcServiceName": { + "type": "string" + }, + "group": { + "type": "string", + "nullable": true + }, + "geoCheckEnabled": { + "type": "boolean" + }, + "geoCheckLocations": { + "type": "array", + "items": { + "type": "string", + "enum": ["EU", "NA", "AS", "SA", "AF", "OC"] + } + }, + "geoCheckInterval": { + "type": "number" + }, + "dnsServer": { + "type": "string" + }, + "dnsRecordType": { + "type": "string", + "enum": ["A", "AAAA", "CNAME", "MX", "TXT", "NS"] + }, + "teamId": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "lastEvaluatedAt": { + "type": "number" + }, + "id": { + "type": "string" + } + }, + "required": [ + "name", + "type", + "url", + "isActive", + "interval", + "status", + "statusWindowSize", + "statusWindowThreshold", + "ignoreTlsErrors", + "proxyMode", + "useAdvancedMatching", + "method", + "notifications", + "tags", + "cpuAlertThreshold", + "memoryAlertThreshold", + "diskAlertThreshold", + "tempAlertThreshold", + "selectedDisks", + "geoCheckEnabled", + "geoCheckLocations", + "geoCheckInterval", + "teamId", + "userId", + "createdAt", + "updatedAt", + "lastEvaluatedAt", + "id" + ], + "additionalProperties": { + "nullable": true + } + }, + "stats": { + "type": "object", + "properties": { + "aggregate": { + "type": "array", + "items": { + "type": "object", + "properties": { + "_id": { + "type": "string" + }, + "avgCpuPct": { + "type": "number", + "nullable": true + }, + "avgMemoryUsedBytes": { + "type": "number", + "nullable": true + }, + "avgMemoryPct": { + "type": "number", + "nullable": true + }, + "minRestartCount": { + "type": "number", + "nullable": true + }, + "maxRestartCount": { + "type": "number", + "nullable": true + } + }, + "required": ["_id", "avgCpuPct", "avgMemoryUsedBytes", "avgMemoryPct", "minRestartCount", "maxRestartCount"] + } + }, + "restartsInRange": { + "type": "number" + }, + "latest": { + "type": "object", + "nullable": true, + "properties": { + "container": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "state": { + "type": "string", + "enum": ["created", "running", "paused", "restarting", "removing", "exited", "dead"] + }, + "status": { + "type": "string" + }, + "health": { + "type": "string", + "enum": ["healthy", "unhealthy", "starting", "none"] + }, + "cpuPct": { + "type": "number" + }, + "memoryUsedBytes": { + "type": "number" + }, + "memoryLimitBytes": { + "type": "number" + }, + "memoryPct": { + "type": "number" + }, + "restartCount": { + "type": "number" + }, + "startedAt": { + "type": "string" + } + }, + "required": ["id", "name", "image", "state", "status", "health"] + }, + "checkedAt": { + "type": "string" + } + }, + "required": ["container", "checkedAt"] + } + }, + "required": ["aggregate", "restartsInRange", "latest"] + } + }, + "required": ["monitor", "stats"] + } + }, + "required": ["success", "msg", "data"] + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [false] + }, + "msg": { + "type": "string" + }, + "data": { + "nullable": true + } + }, + "required": ["success", "msg"] + } + } + } + } + } + } + }, "/monitors/pagespeed/details/{monitorId}": { "get": { "tags": ["monitors"], diff --git a/server/openapi/routes/monitor.ts b/server/openapi/routes/monitor.ts index fb388c909..f7d92c94e 100644 --- a/server/openapi/routes/monitor.ts +++ b/server/openapi/routes/monitor.ts @@ -18,7 +18,10 @@ import { getHardwareDetailsByIdQueryValidation, getDockerDetailsByIdParamValidation, getDockerDetailsByIdQueryValidation, + getDockerContainerNameParamValidation, + getDockerContainerByNameQueryValidation, dockerDetailsResponseSchema, + dockerContainerDetailsResponseSchema, monitorResponseSchema, uptimeDetailsResponseSchema, } from "@/api/validation/monitorValidation.js"; @@ -118,6 +121,16 @@ registry.registerPath({ responses: { "200": okJson(dockerDetailsResponseSchema), ...standardErrors }, }); +registry.registerPath({ + method: "get", + path: "/monitors/docker/details/{monitorId}/containers/{containerName}", + tags, + summary: "Get details for one container on a Docker host monitor", + security: bearer, + request: { params: getDockerContainerNameParamValidation, query: getDockerContainerByNameQueryValidation }, + responses: { "200": okJson(dockerContainerDetailsResponseSchema), ...standardErrors }, +}); + registry.registerPath({ method: "get", path: "/monitors/pagespeed/details/{monitorId}", diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index aabf6a957..3b94f6989 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -484,3 +484,29 @@ export const dockerDetailsResponseSchema = z.object({ }), monitorStats: monitorStatsResponseSchema.nullable(), }); + +// Keep aligned with DockerContainerStatsBucket in domain/checks/check.type.ts. +export const dockerContainerStatsBucketResponseSchema = z.object({ + _id: z.string(), + avgCpuPct: z.number().nullable(), + avgMemoryUsedBytes: z.number().nullable(), + avgMemoryPct: z.number().nullable(), + minRestartCount: z.number().nullable(), + maxRestartCount: z.number().nullable(), +}); + +// Response of GET /monitors/docker/details/{monitorId}/containers/{containerName}. Keep +// aligned with DockerContainerDetailsResult in domain/monitors/monitor.type.ts. +export const dockerContainerDetailsResponseSchema = z.object({ + monitor: monitorResponseSchema.omit({ _id: true }).extend({ id: z.string() }), + stats: z.object({ + aggregate: z.array(dockerContainerStatsBucketResponseSchema), + restartsInRange: z.number(), + latest: z + .object({ + container: dockerContainerResponseSchema, + checkedAt: z.string(), + }) + .nullable(), + }), +}); diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index c725fcd10..79753e41c 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -29,6 +29,7 @@ const createMonitorsRepositoryMock = () => const createChecksRepositoryMock = () => ({ findByDateRangeAndMonitorId: jest.fn(), + findDockerContainerChecks: jest.fn(), deleteByMonitorId: jest.fn(), }) as unknown as IChecksRepository; @@ -486,6 +487,113 @@ describe("MonitorService", () => { }); }); + describe("getDockerContainerByName", () => { + const makeBucket = (overrides: Partial> = {}) => ({ + _id: "2024-01-01T00:00:00Z", + avgCpuPct: 12.5, + avgMemoryUsedBytes: 1024, + avgMemoryPct: 50, + minRestartCount: 0, + maxRestartCount: 0, + ...overrides, + }); + + it("returns container details for a docker monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + const monitor = makeMonitor({ type: "docker" }); + const container = { id: "abc", name: "checkmate-mongo", image: "mongo:8.0", state: "running", status: "Up 3 hours", health: "healthy" }; + (monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor); + (checksRepository.findDockerContainerChecks as jest.Mock).mockResolvedValue({ + aggregate: [makeBucket()], + latest: { container, checkedAt: "2024-01-01T00:00:00Z" }, + }); + + const { service } = createService({ monitorsRepository, checksRepository }); + const result = await service.getDockerContainerByName({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + containerName: "checkmate-mongo", + dateRange: "recent", + }); + + expect(checksRepository.findDockerContainerChecks).toHaveBeenCalledWith(MONITOR_ID, "checkmate-mongo", "recent"); + expect(result.monitor).toMatchObject({ id: MONITOR_ID }); + expect(result.stats.latest?.container).toMatchObject({ name: "checkmate-mongo" }); + expect(result.stats.restartsInRange).toBe(0); + }); + + it("sums restarts within and between buckets", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + (checksRepository.findDockerContainerChecks as jest.Mock).mockResolvedValue({ + aggregate: [ + makeBucket({ _id: "01", minRestartCount: 0, maxRestartCount: 2 }), + makeBucket({ _id: "02", minRestartCount: 3, maxRestartCount: 3 }), + makeBucket({ _id: "03", minRestartCount: null, maxRestartCount: null }), + makeBucket({ _id: "04", minRestartCount: 3, maxRestartCount: 4 }), + ], + latest: null, + }); + + const { service } = createService({ monitorsRepository, checksRepository }); + const result = await service.getDockerContainerByName({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + containerName: "checkmate-mongo", + dateRange: "day", + }); + + // 2 within bucket 01, +1 between 01 and 02, +1 within 04 (null bucket skipped) + expect(result.stats.restartsInRange).toBe(4); + }); + + it("counts a restart-count drop as a recreate", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const checksRepository = createChecksRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + (checksRepository.findDockerContainerChecks as jest.Mock).mockResolvedValue({ + aggregate: [ + makeBucket({ _id: "01", minRestartCount: 5, maxRestartCount: 5 }), + makeBucket({ _id: "02", minRestartCount: 1, maxRestartCount: 1 }), + ], + latest: null, + }); + + const { service } = createService({ monitorsRepository, checksRepository }); + const result = await service.getDockerContainerByName({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + containerName: "checkmate-mongo", + dateRange: "day", + }); + + // counter dropped 5 -> 1: the recreate itself plus the 1 restart the new counter shows + expect(result.stats.restartsInRange).toBe(2); + }); + + it("throws 404 when monitor not found", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(null); + const { service } = createService({ monitorsRepository }); + + await expect( + service.getDockerContainerByName({ teamId: TEAM_ID, monitorId: "missing", containerName: "checkmate-mongo", dateRange: "recent" }) + ).rejects.toThrow("Monitor with ID missing not found."); + }); + + it("throws 400 when monitor type is not docker", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "http" })); + const { service } = createService({ monitorsRepository }); + + await expect( + service.getDockerContainerByName({ teamId: TEAM_ID, monitorId: MONITOR_ID, containerName: "checkmate-mongo", dateRange: "recent" }) + ).rejects.toThrow("monitors are not supported for docker container details"); + }); + }); + describe("getPageSpeedDetailsById", () => { it("returns pagespeed details for a pagespeed monitor", async () => { const monitorsRepository = createMonitorsRepositoryMock(); From 56153b38a50a6fc733fe3aebdda963ba75762250 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 1 Sep 2026 11:23:03 -0700 Subject: [PATCH 047/137] containers detail charts --- .../Components/design-elements/BaseChart.tsx | 28 ++++- .../Components/monitors/charts/ChartUtils.ts | 34 ------ .../Components/monitors/charts/ChartUtils.tsx | 113 ++++++++++++++++++ .../charts/HistogramDockerContainer.tsx | 70 +++++++++++ .../charts/HistogramInfrastructure.tsx | 80 +------------ .../Docker/Details/Components/Charts.tsx | 0 .../Docker/Details/Components/StatBoxes.tsx | 54 +++++++++ client/src/Pages/Docker/Details/TabLogs.tsx | 3 + .../src/Pages/Docker/Details/TabOverview.tsx | 62 ++++++++++ client/src/Pages/Docker/Details/index.tsx | 74 +++++++++++- .../Components/DockerContainersTable.tsx | 13 +- client/src/Pages/Docker/HostDetails/index.tsx | 5 +- .../Details/Components/Charts.tsx | 4 +- client/src/Routes/index.tsx | 2 +- client/src/Types/Monitor.ts | 25 +++- client/src/locales/en.json | 24 +++- 16 files changed, 451 insertions(+), 140 deletions(-) delete mode 100644 client/src/Components/monitors/charts/ChartUtils.ts create mode 100644 client/src/Components/monitors/charts/ChartUtils.tsx create mode 100644 client/src/Components/monitors/charts/HistogramDockerContainer.tsx create mode 100644 client/src/Pages/Docker/Details/Components/Charts.tsx create mode 100644 client/src/Pages/Docker/Details/Components/StatBoxes.tsx create mode 100644 client/src/Pages/Docker/Details/TabLogs.tsx create mode 100644 client/src/Pages/Docker/Details/TabOverview.tsx diff --git a/client/src/Components/design-elements/BaseChart.tsx b/client/src/Components/design-elements/BaseChart.tsx index 319a4ad51..e5233eb0f 100644 --- a/client/src/Components/design-elements/BaseChart.tsx +++ b/client/src/Components/design-elements/BaseChart.tsx @@ -10,6 +10,7 @@ import { useTheme } from "@mui/material/styles"; type BaseChartProps = React.PropsWithChildren<{ icon?: React.ReactNode; title: string; + rightTitle?: string; width?: number | string; maxWidth?: number | string; flexBasis?: number | string; @@ -20,6 +21,7 @@ type BaseChartProps = React.PropsWithChildren<{ export const BaseChart = ({ children, title, + rightTitle, width = "100%", maxWidth = "100%", flexBasis = "0%", @@ -47,13 +49,27 @@ export const BaseChart = ({ gap={theme.spacing(LAYOUT.MD)} flex={1} > - - {title} - + + {title} + + {rightTitle && ( + + {rightTitle} + + )} + {children} diff --git a/client/src/Components/monitors/charts/ChartUtils.ts b/client/src/Components/monitors/charts/ChartUtils.ts deleted file mode 100644 index 0516eaac3..000000000 --- a/client/src/Components/monitors/charts/ChartUtils.ts +++ /dev/null @@ -1,34 +0,0 @@ -export const computeYAxisCap = (values: number[]): number | undefined => { - const sorted = values.slice().sort((a, b) => a - b); - if (sorted.length < 2) return undefined; - const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))]; - return p95 > 0 ? Math.ceil(p95 * 2) : undefined; -}; - -export const PHASE_KEYS = [ - "avgDns", - "avgTcp", - "avgTls", - "avgRequest", - "avgFirstByte", - "avgDownload", -] as const; - -export const PHASE_COLOR_KEYS = { - avgDns: "dns", - avgTcp: "tcp", - avgTls: "tls", - avgRequest: "request", - avgFirstByte: "firstByte", - avgDownload: "download", -} as const; - -// i18n keys, resolved with t() at render -export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = { - avgDns: "common.charts.phases.dns", - avgTcp: "common.charts.phases.tcp", - avgTls: "common.charts.phases.tls", - avgRequest: "common.charts.phases.request", - avgFirstByte: "common.charts.phases.firstByte", - avgDownload: "common.charts.phases.download", -}; diff --git a/client/src/Components/monitors/charts/ChartUtils.tsx b/client/src/Components/monitors/charts/ChartUtils.tsx new file mode 100644 index 000000000..c90fdb6d5 --- /dev/null +++ b/client/src/Components/monitors/charts/ChartUtils.tsx @@ -0,0 +1,113 @@ +export const computeYAxisCap = (values: number[]): number | undefined => { + const sorted = values.slice().sort((a, b) => a - b); + if (sorted.length < 2) return undefined; + const p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))]; + return p95 > 0 ? Math.ceil(p95 * 2) : undefined; +}; + +export const PHASE_KEYS = [ + "avgDns", + "avgTcp", + "avgTls", + "avgRequest", + "avgFirstByte", + "avgDownload", +] as const; + +export const PHASE_COLOR_KEYS = { + avgDns: "dns", + avgTcp: "tcp", + avgTls: "tls", + avgRequest: "request", + avgFirstByte: "firstByte", + avgDownload: "download", +} as const; + +// i18n keys, resolved with t() at render +export const PHASE_LABEL_KEYS: Record<(typeof PHASE_KEYS)[number], string> = { + avgDns: "common.charts.phases.dns", + avgTcp: "common.charts.phases.tcp", + avgTls: "common.charts.phases.tls", + avgRequest: "common.charts.phases.request", + avgFirstByte: "common.charts.phases.firstByte", + avgDownload: "common.charts.phases.download", +}; + +export const AREA_COLORS = [ + // Blues + "#3182bd", // Deep blue + "#6baed6", // Medium blue + "#9ecae1", // Light blue + + // Greens + "#74c476", // Soft green + "#a1d99b", // Light green + "#c7e9c0", // Pale green + + // Oranges + "#fdae6b", // Warm orange + "#fdd0a2", // Light orange + "#feedde", // Pale orange + + // Purples + "#9467bd", // Lavender + "#a55194", // Deep magenta + "#c994c7", // Soft magenta + + // Reds + "#ff9896", // Soft red + "#de2d26", // Deep red + "#fc9272", // Medium red + + // Cyans/Teals + "#17becf", // Cyan + "#7fcdbb", // Teal + "#a1dab4", // Light teal + + // Yellows + "#fec44f", // Mustard + "#fee391", // Light yellow + "#ffffd4", // Pale yellow + + // Additional colors + "#e377c2", // Soft pink + "#bcbd22", // Olive + "#2ca02c", // Vibrant green +]; + +export const createGradient = ({ + id, + startColor, + endColor, + startOpacity = 0.8, + endOpacity = 0, + direction = "vertical", +}: { + id: string; + startColor: string; + endColor: string; + startOpacity?: number; + endOpacity?: number; + direction?: "vertical" | "horizontal"; +}) => ( + + + + + + +); diff --git a/client/src/Components/monitors/charts/HistogramDockerContainer.tsx b/client/src/Components/monitors/charts/HistogramDockerContainer.tsx new file mode 100644 index 000000000..b7a420fc9 --- /dev/null +++ b/client/src/Components/monitors/charts/HistogramDockerContainer.tsx @@ -0,0 +1,70 @@ +import { BaseChart } from "@/Components/design-elements"; +import { Area, AreaChart, CartesianGrid, ResponsiveContainer } from "recharts"; + +// Types +import type { DockerContainerStats } from "@/Types/Monitor"; + +// Hooks +import { useTheme } from "@mui/material"; +import { Fragment, useId } from "react"; + +// Utils +import { createGradient } from "@/Components/monitors/charts/ChartUtils"; +interface HistogramDockerContainerProps { + title: string; + rightTitle: string; + stats: DockerContainerStats; + dataKey: string; + strokeColor: string; + gradientStartColor: string; +} + +export const HistogramDockerContainer = ({ + title, + rightTitle, + stats, + dataKey, + strokeColor, + gradientStartColor, +}: HistogramDockerContainerProps) => { + const uniqueId = useId(); + const theme = useTheme(); + const gradientId = `gradient-${uniqueId}`; + + return ( + + + + + + {createGradient({ + id: gradientId, + startColor: gradientStartColor, + endColor: "transparent", + direction: "vertical", + })} + + + + + + ); +}; diff --git a/client/src/Components/monitors/charts/HistogramInfrastructure.tsx b/client/src/Components/monitors/charts/HistogramInfrastructure.tsx index 8163898aa..bd3151364 100644 --- a/client/src/Components/monitors/charts/HistogramInfrastructure.tsx +++ b/client/src/Components/monitors/charts/HistogramInfrastructure.tsx @@ -12,85 +12,7 @@ import { XTick } from "@/Components/monitors/charts/XTick"; import { useTheme } from "@mui/material/styles"; import type { HardwareCheckStats } from "@/Types/Monitor"; - -const AREA_COLORS = [ - // Blues - "#3182bd", // Deep blue - "#6baed6", // Medium blue - "#9ecae1", // Light blue - - // Greens - "#74c476", // Soft green - "#a1d99b", // Light green - "#c7e9c0", // Pale green - - // Oranges - "#fdae6b", // Warm orange - "#fdd0a2", // Light orange - "#feedde", // Pale orange - - // Purples - "#9467bd", // Lavender - "#a55194", // Deep magenta - "#c994c7", // Soft magenta - - // Reds - "#ff9896", // Soft red - "#de2d26", // Deep red - "#fc9272", // Medium red - - // Cyans/Teals - "#17becf", // Cyan - "#7fcdbb", // Teal - "#a1dab4", // Light teal - - // Yellows - "#fec44f", // Mustard - "#fee391", // Light yellow - "#ffffd4", // Pale yellow - - // Additional colors - "#e377c2", // Soft pink - "#bcbd22", // Olive - "#2ca02c", // Vibrant green -]; - -const createGradient = ({ - id, - startColor, - endColor, - startOpacity = 0.8, - endOpacity = 0, - direction = "vertical", -}: { - id: string; - startColor: string; - endColor: string; - startOpacity?: number; - endOpacity?: number; - direction?: "vertical" | "horizontal"; -}) => ( - - - - - - -); +import { AREA_COLORS, createGradient } from "@/Components/monitors/charts/ChartUtils"; export const HistogramInfrastructure = ({ dateRange, diff --git a/client/src/Pages/Docker/Details/Components/Charts.tsx b/client/src/Pages/Docker/Details/Components/Charts.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/Pages/Docker/Details/Components/StatBoxes.tsx b/client/src/Pages/Docker/Details/Components/StatBoxes.tsx new file mode 100644 index 000000000..79bf89e16 --- /dev/null +++ b/client/src/Pages/Docker/Details/Components/StatBoxes.tsx @@ -0,0 +1,54 @@ +import Stack from "@mui/material/Stack"; +import { StatBox } from "@/Components/design-elements"; + +// Types +import type { Monitor } from "@/Types/Monitor"; + +// Hooks +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import type { DockerContainerInfo } from "@/Types/Check"; + +// Utils +import { formatDuration } from "@/Utils/TimeUtils"; + +export const DockerContainerStatusBoxes = ({ + monitor, + container, +}: { + monitor: Monitor | undefined; + container: DockerContainerInfo | undefined; +}) => { + const { t } = useTranslation(); + const theme = useTheme(); + + if (!container) return null; + + const { state, restartCount, health } = container; + const startedAt = container.startedAt ?? "0"; + const runningFor = Date.now() - new Date(startedAt).getTime(); + return ( + + + + + + + ); +}; diff --git a/client/src/Pages/Docker/Details/TabLogs.tsx b/client/src/Pages/Docker/Details/TabLogs.tsx new file mode 100644 index 000000000..9dcea4842 --- /dev/null +++ b/client/src/Pages/Docker/Details/TabLogs.tsx @@ -0,0 +1,3 @@ +export const TabLogs = () => { + return "This is the logs tab"; +}; diff --git a/client/src/Pages/Docker/Details/TabOverview.tsx b/client/src/Pages/Docker/Details/TabOverview.tsx new file mode 100644 index 000000000..ce746cc19 --- /dev/null +++ b/client/src/Pages/Docker/Details/TabOverview.tsx @@ -0,0 +1,62 @@ +// Components +import Grid from "@mui/material/Grid"; +import { HistogramDockerContainer } from "@/Components/monitors/charts/HistogramDockerContainer"; + +// Hooks +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import useMediaQuery from "@mui/material/useMediaQuery"; + +// Types +import type { Theme } from "@mui/material"; +import type { TFunction } from "i18next"; +import type { DockerContainerStats } from "@/Types/Monitor"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { formatPercentage } from "@/Utils/FormatUtils"; +import prettyBytes from "pretty-bytes"; + +const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [ + { + title: t("common.charts.labels.cpuUsage"), + key: "avgCpuPct", + color: theme.palette.success.main, + rightTitle: formatPercentage(stats?.latest?.container.cpuPct || 0), + }, + { + title: t("common.charts.labels.memoryUsage"), + key: "avgMemoryUsedBytes", + color: theme.palette.primary.main, + rightTitle: prettyBytes(stats?.latest?.container.memoryUsedBytes || 0), + }, +]; + +export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => { + const theme = useTheme(); + const { t } = useTranslation(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + + return ( + + {getChartConfigs(theme, t, stats).map((config) => { + return ( + + + + ); + })} + + ); +}; diff --git a/client/src/Pages/Docker/Details/index.tsx b/client/src/Pages/Docker/Details/index.tsx index 5937e113e..33ef7da9e 100644 --- a/client/src/Pages/Docker/Details/index.tsx +++ b/client/src/Pages/Docker/Details/index.tsx @@ -1,5 +1,75 @@ -const DockerDetailsPage = () => { - return null; +// Components +import { BasePage, Tabs, Tab, DockerStateLabel } from "@/Components/design-elements"; +import { TabOverview } from "@/Pages/Docker/Details/TabOverview"; +import { TabLogs } from "@/Pages/Docker/Details/TabLogs"; + +// Hooks +import { useParams } from "react-router-dom"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useGet } from "@/Hooks/UseApi"; +import type { DockerContainerResponse } from "@/Types/Monitor"; +import { DockerContainerStatusBoxes } from "@/Pages/Docker/Details/Components/StatBoxes"; +interface DockerContainerDetailsProps { + open?: "overview" | "logs"; +} + +const TAB_MAP = { + overview: 0, + logs: 1, +} as const; + +const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => { + const [activeTab, setActiveTab] = useState(TAB_MAP[open]); + const { monitorId, containerName } = useParams<{ + monitorId: string; + containerName: string; + }>(); + const { t } = useTranslation(); + + const monitorDetailsUrl = monitorId + ? `/monitors/docker/details/${monitorId}/containers/${containerName}` + : null; + + const { data: containerData, refetch: refetchContainer } = + useGet( + monitorDetailsUrl, + {}, + { refreshInterval: 10000, keepPreviousData: true } + ); + + const monitor = containerData?.monitor; + if (!monitor) return null; + + const stats = containerData.stats; + + const container = stats?.latest?.container; + if (!container) return null; + + const { state, name, image } = container; + + return ( + + + + setActiveTab(newValue)} + > + + + + {activeTab === 0 && } + {activeTab === 1 && } + + ); }; export default DockerDetailsPage; diff --git a/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx b/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx index bf5bdc216..72d828f45 100644 --- a/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx +++ b/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx @@ -15,11 +15,15 @@ import type { DockerContainerInfo } from "@/Types/Check"; import { formatPercentage } from "@/Utils/FormatUtils"; interface DockerContainersTableProps { + monitorId: string | undefined; containers: DockerContainerInfo[]; onRowClick?: (container: DockerContainerInfo) => void; } -export const DockerContainersTable = ({ containers }: DockerContainersTableProps) => { +export const DockerContainersTable = ({ + monitorId, + containers, +}: DockerContainersTableProps) => { const { t } = useTranslation(); const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); @@ -64,7 +68,7 @@ export const DockerContainersTable = ({ containers }: DockerContainersTableProps }, { id: "health", - content: t("pages.docker.host.table.headers.health"), + content: t("common.labels.health"), render: (row) => { return {row.health}; }, @@ -85,7 +89,7 @@ export const DockerContainersTable = ({ containers }: DockerContainersTableProps }, { id: "restarts", - content: t("pages.docker.host.table.headers.restarts"), + content: t("common.labels.restarts"), render: (row) => { return {row.restartCount}; }, @@ -100,14 +104,13 @@ export const DockerContainersTable = ({ containers }: DockerContainersTableProps headers = headers.filter((h) => h.id !== "histogram"); } - console.log(JSON.stringify(containers, null, 2)); return (
    { - navigate(`/docker/container/${row.id}`); + navigate(`/docker/host/${monitorId}/container/${row.name}`); }} // getRowSx={(row) => ({ // backgroundColor: isRowSelected(row.id) diff --git a/client/src/Pages/Docker/HostDetails/index.tsx b/client/src/Pages/Docker/HostDetails/index.tsx index 4cd2d3903..7c2d3c80c 100644 --- a/client/src/Pages/Docker/HostDetails/index.tsx +++ b/client/src/Pages/Docker/HostDetails/index.tsx @@ -44,7 +44,10 @@ const DockerHostDetailsPage = () => { monitorStats={monitorStats} /> - + ); }; diff --git a/client/src/Pages/Infrastructure/Details/Components/Charts.tsx b/client/src/Pages/Infrastructure/Details/Components/Charts.tsx index 4309feca7..4ef5fa145 100644 --- a/client/src/Pages/Infrastructure/Details/Components/Charts.tsx +++ b/client/src/Pages/Infrastructure/Details/Components/Charts.tsx @@ -12,7 +12,7 @@ const formatTemp = (value: number) => `${value.toFixed(0)}°C`; const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => { return [ { - title: t("pages.infrastructure.charts.labels.memory"), + title: t("common.charts.labels.memoryUsage"), type: "memory", dataKeys: ["avgMemoryUsage"], strokeColor: theme.palette.primary.main, @@ -22,7 +22,7 @@ const getChartConfigs = (theme: any, checks: HardwareCheckStats[], t: any) => { idx: null, }, { - title: t("pages.infrastructure.charts.labels.cpu"), + title: t("common.charts.labels.cpuUsage"), type: "cpu", dataKeys: ["avgCpuUsage"], strokeColor: theme.palette.success.main, diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx index e7735fcd0..a3fccf378 100644 --- a/client/src/Routes/index.tsx +++ b/client/src/Routes/index.tsx @@ -157,7 +157,7 @@ const Routes = () => { element={} /> } /> Date: Tue, 1 Sep 2026 23:17:11 +0200 Subject: [PATCH 048/137] feat: add Signalgrid notification provider --- .../src/Pages/Notifications/create/index.tsx | 26 +++ client/src/Types/Notification.ts | 1 + client/src/Validation/notifications.ts | 7 + client/src/locales/en.json | 9 + server/openapi/routes/notification.ts | 4 + .../api/validation/notificationValidation.ts | 7 + server/src/config/services.shared.ts | 3 + .../notifications/notification.model.ts | 1 + .../domain/notifications/notification.type.ts | 1 + .../notifications/providers/signalgrid.ts | 123 ++++++++++++++ .../notifications/signalgridProvider.test.ts | 155 ++++++++++++++++++ 11 files changed, 337 insertions(+) create mode 100644 server/src/domain/notifications/providers/signalgrid.ts create mode 100644 server/test/unit/providers/notifications/signalgridProvider.test.ts diff --git a/client/src/Pages/Notifications/create/index.tsx b/client/src/Pages/Notifications/create/index.tsx index 8063f16b6..c4f339bf1 100644 --- a/client/src/Pages/Notifications/create/index.tsx +++ b/client/src/Pages/Notifications/create/index.tsx @@ -126,6 +126,7 @@ const NotificationsCreatePage = () => { {watchedType !== "matrix" && watchedType !== "telegram" && watchedType !== "pushover" && + watchedType !== "signalgrid" && watchedType !== "twilio" && watchedType !== "ntfy" && ( { } /> )} + {watchedType === "signalgrid" && ( + + + + + + } + /> + )} {watchedType === "twilio" && ( ( "teams", "telegram", "pushover", + "signalgrid", "twilio", "ntfy", ] as NotificationChannel[], diff --git a/server/src/domain/notifications/notification.type.ts b/server/src/domain/notifications/notification.type.ts index a45d4c4f8..628633d56 100644 --- a/server/src/domain/notifications/notification.type.ts +++ b/server/src/domain/notifications/notification.type.ts @@ -9,6 +9,7 @@ export const NotificationChannels = [ "teams", "telegram", "pushover", + "signalgrid", "twilio", "ntfy", ] as const; diff --git a/server/src/domain/notifications/providers/signalgrid.ts b/server/src/domain/notifications/providers/signalgrid.ts new file mode 100644 index 000000000..fdd930724 --- /dev/null +++ b/server/src/domain/notifications/providers/signalgrid.ts @@ -0,0 +1,123 @@ +const SERVICE_NAME = "SignalgridProvider"; +import type { Notification, NotificationMessage, NotificationSeverity } from "@/domain/notifications/notification.type.js"; +import { NotificationProvider } from "@/domain/notifications/providers/INotificationProvider.js"; +import { getTestMessage } from "@/domain/notifications/providers/utils.js"; +import got from "got"; + +export class SignalgridProvider extends NotificationProvider { + async sendTestAlert(notification: Partial): Promise { + if (!notification.address || !notification.accessToken) { + return false; + } + + try { + await got.post("https://api.signalgrid.co/v1/push", { + form: { + client_key: notification.accessToken, + channel: notification.address, + title: "Checkmate Test Notification", + body: getTestMessage(), + type: "INFO", + critical: false, + }, + ...this.gotRequestOptions(), + }); + return true; + } catch (error) { + const errMsg = error instanceof Error ? error.message : "unknown error"; + const errStack = error instanceof Error ? error.stack : undefined; + this.logger.warn({ + message: "Signalgrid test alert failed", + service: SERVICE_NAME, + method: "sendTestAlert", + stack: errStack, + details: { error: errMsg }, + }); + return false; + } + } + + async sendMessage(notification: Notification, message: NotificationMessage): Promise { + if (!notification.address || !notification.accessToken) { + return false; + } + + try { + await got.post("https://api.signalgrid.co/v1/push", { + form: { + client_key: notification.accessToken, + channel: notification.address, + title: message.content.title, + body: this.buildSignalgridText(message), + type: this.mapSeverity(message.severity), + critical: false, + }, + ...this.gotRequestOptions(), + }); + + this.logger.info({ + message: "Signalgrid notification sent", + service: SERVICE_NAME, + method: "sendMessage", + }); + return true; + } catch (error) { + const errMsg = error instanceof Error ? error.message : "unknown error"; + const errStack = error instanceof Error ? error.stack : undefined; + this.logger.warn({ + message: "Signalgrid alert failed", + service: SERVICE_NAME, + method: "sendMessage", + stack: errStack, + details: { error: errMsg }, + }); + return false; + } + } + + private mapSeverity(severity: NotificationSeverity): "CRIT" | "WARN" | "INFO" | "SUCCESS" { + switch (severity) { + case "critical": + return "CRIT"; + case "warning": + return "WARN"; + case "success": + return "SUCCESS"; + default: + return "INFO"; + } + } + + private buildSignalgridText(message: NotificationMessage): string { + const lines: string[] = []; + + lines.push(message.content.summary); + lines.push(""); + lines.push("Monitor Details:"); + lines.push(`• Name: ${message.monitor.name}`); + lines.push(`• URL: ${message.monitor.url}`); + lines.push(`• Type: ${message.monitor.type}`); + lines.push(`• Status: ${message.monitor.status}`); + + if (message.content.details && message.content.details.length > 0) { + lines.push(""); + lines.push("Additional Information:"); + message.content.details.forEach((detail) => lines.push(`• ${detail}`)); + } + + if (message.content.thresholds && message.content.thresholds.length > 0) { + lines.push(""); + lines.push("Threshold Breaches:"); + message.content.thresholds.forEach((breach) => { + lines.push(`• ${breach.metric.toUpperCase()}: ${breach.formattedValue} (threshold: ${breach.threshold}${breach.unit})`); + }); + } + + if (message.content.incident) { + lines.push(""); + lines.push(`View Incident: ${message.clientHost}/incidents/${message.monitor.id}`); + } + + return lines.join("\n"); + } +} diff --git a/server/test/unit/providers/notifications/signalgridProvider.test.ts b/server/test/unit/providers/notifications/signalgridProvider.test.ts new file mode 100644 index 000000000..8c40bb1d9 --- /dev/null +++ b/server/test/unit/providers/notifications/signalgridProvider.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { createMockLogger } from "../../../helpers/createMockLogger.ts"; +import { makeNotification, makeMessage, makeMessageWithThresholds, makeMessageWithIncident } from "../../../helpers/notificationMessage.ts"; +import { testNotificationProviderContract } from "../../../helpers/notificationProviderContract.ts"; + +const mockGotPost = jest.fn().mockResolvedValue({}); +jest.unstable_mockModule("got", () => ({ default: { post: mockGotPost } })); + +const { SignalgridProvider } = await import("../../../../src/domain/notifications/providers/signalgrid.ts"); + +const createProvider = () => { + const logger = createMockLogger(); + return { provider: new SignalgridProvider(logger as any), logger }; +}; + +testNotificationProviderContract("SignalgridProvider", { + create: () => { + mockGotPost.mockResolvedValue({}); + return createProvider().provider; + }, + makeNotification: () => makeNotification(), +}); + +describe("SignalgridProvider", () => { + beforeEach(() => mockGotPost.mockReset().mockResolvedValue({})); + + describe("sendTestAlert", () => { + it("sends to Signalgrid API and returns true", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification())).toBe(true); + expect(mockGotPost).toHaveBeenCalledWith( + "https://api.signalgrid.co/v1/push", + expect.objectContaining({ + form: expect.objectContaining({ + client_key: "token-abc", + channel: "https://hooks.example.com/webhook", + type: "INFO", + critical: false, + }), + }) + ); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification({ address: "" }))).toBe(false); + }); + + it("returns false when accessToken is missing", async () => { + expect(await createProvider().provider.sendTestAlert(makeNotification({ accessToken: undefined }))).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendTestAlert", details: { error: "fail" } })); + }); + + it("handles non-Error thrown values in sendTestAlert", async () => { + mockGotPost.mockRejectedValue("string error"); + const { provider, logger } = createProvider(); + expect(await provider.sendTestAlert(makeNotification())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined, details: { error: "unknown error" } })); + }); + }); + + describe("sendMessage", () => { + it("sends message and returns true", async () => { + const { provider } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(true); + const form = mockGotPost.mock.calls[0][1].form; + expect(form.body).toContain("Monitor Details:"); + expect(form.title).toBeDefined(); + expect(form.critical).toBe(false); + }); + + it("maps critical severity to CRIT", async () => { + const { provider } = createProvider(); + const message = makeMessage(); + message.severity = "critical"; + await provider.sendMessage(makeNotification() as any, message); + expect(mockGotPost.mock.calls[0][1].form.type).toBe("CRIT"); + }); + + it("maps warning severity to WARN", async () => { + const { provider } = createProvider(); + const message = makeMessage(); + message.severity = "warning"; + await provider.sendMessage(makeNotification() as any, message); + expect(mockGotPost.mock.calls[0][1].form.type).toBe("WARN"); + }); + + it("maps success severity to SUCCESS", async () => { + const { provider } = createProvider(); + const message = makeMessage(); + message.severity = "success"; + await provider.sendMessage(makeNotification() as any, message); + expect(mockGotPost.mock.calls[0][1].form.type).toBe("SUCCESS"); + }); + + it("maps info severity to INFO", async () => { + const { provider } = createProvider(); + const message = makeMessage(); + message.severity = "info"; + await provider.sendMessage(makeNotification() as any, message); + expect(mockGotPost.mock.calls[0][1].form.type).toBe("INFO"); + }); + + it("returns false when address is missing", async () => { + expect(await createProvider().provider.sendMessage(makeNotification({ address: "" }) as any, makeMessage())).toBe(false); + }); + + it("returns false when accessToken is missing", async () => { + expect(await createProvider().provider.sendMessage(makeNotification({ accessToken: undefined }) as any, makeMessage())).toBe(false); + }); + + it("returns false and logs on error", async () => { + mockGotPost.mockRejectedValue(new Error("fail")); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ method: "sendMessage" })); + }); + + it("handles non-Error thrown values in sendMessage", async () => { + mockGotPost.mockRejectedValue(42); + const { provider, logger } = createProvider(); + expect(await provider.sendMessage(makeNotification() as any, makeMessage())).toBe(false); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ details: { error: "unknown error" } })); + }); + + it("includes thresholds in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessageWithThresholds()); + expect(mockGotPost.mock.calls[0][1].form.body).toContain("CPU"); + }); + + it("includes incident link in text", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNotification() as any, makeMessageWithIncident()); + expect(mockGotPost.mock.calls[0][1].form.body).toContain("View Incident"); + }); + + it("omits optional sections when not present", async () => { + const { provider } = createProvider(); + const msg = makeMessage(); + msg.content.thresholds = undefined; + msg.content.details = undefined; + msg.content.incident = undefined; + await provider.sendMessage(makeNotification() as any, msg); + const text = mockGotPost.mock.calls[0][1].form.body; + expect(text).not.toContain("Threshold"); + expect(text).not.toContain("Additional"); + expect(text).not.toContain("View Incident"); + }); + }); +}); From 786c8038479cdc5a9d8d28b26ac82ed89e988f13 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 2 Sep 2026 09:45:28 -0700 Subject: [PATCH 049/137] add port/mount display --- .../common/controls/HeaderTimeRange.tsx | 7 +- client/src/Pages/Checks/index.tsx | 3 +- .../Components/Charts.tsx | 0 .../Components/StatBoxes.tsx | 0 .../{Details => ContainerDetails}/TabLogs.tsx | 0 .../TabOverview.tsx | 55 ++++++++++- .../{Details => ContainerDetails}/index.tsx | 19 ++-- client/src/Pages/Incidents/index.tsx | 3 +- .../Pages/Infrastructure/Details/index.tsx | 3 +- client/src/Pages/PageSpeed/Details/index.tsx | 3 +- client/src/Pages/Uptime/Details/index.tsx | 3 +- client/src/Routes/index.tsx | 2 +- client/src/Types/Check.ts | 27 ++++- client/src/Types/Query.ts | 2 + client/src/Utils/MonitorUtils.ts | 35 ++++++- server/openapi.json | 98 +++++++++++++++++++ .../src/api/validation/monitorValidation.ts | 21 +++- server/src/domain/checks/check.model.ts | 34 ++++++- server/src/service/network/DockerProvider.ts | 46 +++++++++ server/src/types/network.ts | 21 ++++ .../providers/network/dockerProvider.test.ts | 83 ++++++++++++++++ 21 files changed, 441 insertions(+), 24 deletions(-) rename client/src/Pages/Docker/{Details => ContainerDetails}/Components/Charts.tsx (100%) rename client/src/Pages/Docker/{Details => ContainerDetails}/Components/StatBoxes.tsx (100%) rename client/src/Pages/Docker/{Details => ContainerDetails}/TabLogs.tsx (100%) rename client/src/Pages/Docker/{Details => ContainerDetails}/TabOverview.tsx (52%) rename client/src/Pages/Docker/{Details => ContainerDetails}/index.tsx (75%) create mode 100644 client/src/Types/Query.ts diff --git a/client/src/Components/common/controls/HeaderTimeRange.tsx b/client/src/Components/common/controls/HeaderTimeRange.tsx index d7c7102bc..254de71b2 100644 --- a/client/src/Components/common/controls/HeaderTimeRange.tsx +++ b/client/src/Components/common/controls/HeaderTimeRange.tsx @@ -6,12 +6,13 @@ import { useTheme } from "@mui/material/styles"; import CircularProgress from "@mui/material/CircularProgress"; import { useTranslation } from "react-i18next"; import { useMediaQuery } from "@mui/material"; +import type { DateRange } from "@/Types/Query"; interface MonitorTimeFrameHeaderProps { isLoading?: boolean; hasDateRange?: boolean; - dateRange: string; - setDateRange: (dateRange: string) => void; + dateRange: DateRange; + setDateRange: (dateRange: DateRange) => void; } export const HeaderTimeRange = ({ @@ -25,7 +26,7 @@ export const HeaderTimeRange = ({ const isSmallScreen = useMediaQuery(theme.breakpoints.down("md")); const handleChange = ( _event: React.MouseEvent, - newValue: string | null + newValue: DateRange | null ) => { if (newValue !== null) { setDateRange(newValue); diff --git a/client/src/Pages/Checks/index.tsx b/client/src/Pages/Checks/index.tsx index 93364af44..453660413 100644 --- a/client/src/Pages/Checks/index.tsx +++ b/client/src/Pages/Checks/index.tsx @@ -17,13 +17,14 @@ import { useParams } from "react-router-dom"; import { useGet } from "@/Hooks/UseApi"; import type { Monitor } from "@/Types/Monitor"; import type { ChecksSummary, ChecksResponse } from "@/Types/Check"; +import type { DateRange } from "@/Types/Query"; const Checks = () => { const { t } = useTranslation(); const { monitorId } = useParams<{ monitorId?: string }>(); const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0"); - const [dateRange, setDateRange] = useState("recent"); + const [dateRange, setDateRange] = useState("recent"); const [statusFilter, setStatusFilter] = useState("down"); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); diff --git a/client/src/Pages/Docker/Details/Components/Charts.tsx b/client/src/Pages/Docker/ContainerDetails/Components/Charts.tsx similarity index 100% rename from client/src/Pages/Docker/Details/Components/Charts.tsx rename to client/src/Pages/Docker/ContainerDetails/Components/Charts.tsx diff --git a/client/src/Pages/Docker/Details/Components/StatBoxes.tsx b/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx similarity index 100% rename from client/src/Pages/Docker/Details/Components/StatBoxes.tsx rename to client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx diff --git a/client/src/Pages/Docker/Details/TabLogs.tsx b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx similarity index 100% rename from client/src/Pages/Docker/Details/TabLogs.tsx rename to client/src/Pages/Docker/ContainerDetails/TabLogs.tsx diff --git a/client/src/Pages/Docker/Details/TabOverview.tsx b/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx similarity index 52% rename from client/src/Pages/Docker/Details/TabOverview.tsx rename to client/src/Pages/Docker/ContainerDetails/TabOverview.tsx index ce746cc19..47503bff3 100644 --- a/client/src/Pages/Docker/Details/TabOverview.tsx +++ b/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx @@ -1,9 +1,10 @@ // Components import Grid from "@mui/material/Grid"; +import Stack from "@mui/material/Stack"; import { HistogramDockerContainer } from "@/Components/monitors/charts/HistogramDockerContainer"; // Hooks -import { useTheme } from "@mui/material"; +import { Typography, useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import useMediaQuery from "@mui/material/useMediaQuery"; @@ -11,9 +12,11 @@ import useMediaQuery from "@mui/material/useMediaQuery"; import type { Theme } from "@mui/material"; import type { TFunction } from "i18next"; import type { DockerContainerStats } from "@/Types/Monitor"; -import { LAYOUT } from "@/Utils/Theme/constants"; +import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; import { formatPercentage } from "@/Utils/FormatUtils"; import prettyBytes from "pretty-bytes"; +import { BaseBox } from "@/Components/design-elements"; +import { dedupeDockerPorts, getDockerMountLabel } from "@/Utils/MonitorUtils"; const getChartConfigs = (theme: Theme, t: TFunction, stats: DockerContainerStats) => [ { @@ -35,6 +38,8 @@ export const TabOverview = ({ stats }: { stats: DockerContainerStats }) => { const { t } = useTranslation(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const { ports, mounts } = stats?.latest?.container ?? {}; + const dedupedPorts = dedupeDockerPorts(ports ?? []); return ( { ); })} + + + + Ports + + + {dedupedPorts?.map((port) => { + return ( + {`${port.hostIp}:${port.publicPort} -> ${port.privatePort}/${port.protocol}`} + ); + })} + + + + + + + Volumes + + + {mounts?.map((mount) => { + return ( + + {`${getDockerMountLabel(mount)} -> ${mount.destination}`} + + ); + })} + + + ); }; diff --git a/client/src/Pages/Docker/Details/index.tsx b/client/src/Pages/Docker/ContainerDetails/index.tsx similarity index 75% rename from client/src/Pages/Docker/Details/index.tsx rename to client/src/Pages/Docker/ContainerDetails/index.tsx index 33ef7da9e..e3462d86e 100644 --- a/client/src/Pages/Docker/Details/index.tsx +++ b/client/src/Pages/Docker/ContainerDetails/index.tsx @@ -1,7 +1,7 @@ // Components import { BasePage, Tabs, Tab, DockerStateLabel } from "@/Components/design-elements"; -import { TabOverview } from "@/Pages/Docker/Details/TabOverview"; -import { TabLogs } from "@/Pages/Docker/Details/TabLogs"; +import { TabOverview } from "@/Pages/Docker/ContainerDetails/TabOverview"; +import { TabLogs } from "@/Pages/Docker/ContainerDetails/TabLogs"; // Hooks import { useParams } from "react-router-dom"; @@ -9,7 +9,9 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useGet } from "@/Hooks/UseApi"; import type { DockerContainerResponse } from "@/Types/Monitor"; -import { DockerContainerStatusBoxes } from "@/Pages/Docker/Details/Components/StatBoxes"; +import { DockerContainerStatusBoxes } from "@/Pages/Docker/ContainerDetails/Components/StatBoxes"; +import type { DateRange } from "@/Types/Query"; +import { HeaderTimeRange } from "@/Components/common"; interface DockerContainerDetailsProps { open?: "overview" | "logs"; } @@ -21,19 +23,20 @@ const TAB_MAP = { const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => { const [activeTab, setActiveTab] = useState(TAB_MAP[open]); + const [dateRange, setDateRange] = useState("recent"); const { monitorId, containerName } = useParams<{ monitorId: string; containerName: string; }>(); const { t } = useTranslation(); - const monitorDetailsUrl = monitorId - ? `/monitors/docker/details/${monitorId}/containers/${containerName}` + const containerDetailsUrl = monitorId + ? `/monitors/docker/details/${monitorId}/containers/${containerName}?dateRange=${dateRange}` : null; const { data: containerData, refetch: refetchContainer } = useGet( - monitorDetailsUrl, + containerDetailsUrl, {}, { refreshInterval: 10000, keepPreviousData: true } ); @@ -59,6 +62,10 @@ const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) = container={container} monitor={monitor} /> + setActiveTab(newValue)} diff --git a/client/src/Pages/Incidents/index.tsx b/client/src/Pages/Incidents/index.tsx index 63cd18889..b1d18a8a4 100644 --- a/client/src/Pages/Incidents/index.tsx +++ b/client/src/Pages/Incidents/index.tsx @@ -20,6 +20,7 @@ import { useState, useEffect, useMemo } from "react"; import { useParams } from "react-router-dom"; import type { Incident, IncidentsResponse, IncidentSummary } from "@/Types/Incident"; import type { Monitor } from "@/Types/Monitor"; +import type { DateRange } from "@/Types/Query"; import { useTheme } from "@mui/material"; const IncidentsPage = () => { @@ -30,7 +31,7 @@ const IncidentsPage = () => { // Filter state const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0"); const [filter, setFilter] = useState("all"); - const [dateRange, setDateRange] = useState("recent"); + const [dateRange, setDateRange] = useState("recent"); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); const [activeIncidentsPage, setActiveIncidentsPage] = useState(0); diff --git a/client/src/Pages/Infrastructure/Details/index.tsx b/client/src/Pages/Infrastructure/Details/index.tsx index 202887ad5..ad1d4a334 100644 --- a/client/src/Pages/Infrastructure/Details/index.tsx +++ b/client/src/Pages/Infrastructure/Details/index.tsx @@ -10,6 +10,7 @@ import { useGet } from "@/Hooks/UseApi"; import type { HardwareDetailsResponse } from "@/Types/Monitor"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useTranslation } from "react-i18next"; +import type { DateRange } from "@/Types/Query"; const InfrastructureDetails = () => { const { t } = useTranslation(); @@ -17,7 +18,7 @@ const InfrastructureDetails = () => { const { monitorId } = useParams<{ monitorId: string }>(); - const [dateRange, setDateRange] = useState("recent"); + const [dateRange, setDateRange] = useState("recent"); const [selectedTab, setSelectedTab] = useState(0); const monitorDetailsUrl = useMemo(() => { diff --git a/client/src/Pages/PageSpeed/Details/index.tsx b/client/src/Pages/PageSpeed/Details/index.tsx index ea9da4a7f..9b4e40b7c 100644 --- a/client/src/Pages/PageSpeed/Details/index.tsx +++ b/client/src/Pages/PageSpeed/Details/index.tsx @@ -15,12 +15,13 @@ import { useGet } from "@/Hooks/UseApi"; import { useParams } from "react-router-dom"; import { useTheme } from "@mui/material"; import { useState, useMemo } from "react"; +import type { DateRange } from "@/Types/Query"; const PageSpeedDetails = () => { const { monitorId } = useParams(); const isAdmin = useIsAdmin(); const theme = useTheme(); - const [dateRange, setDateRange] = useState("day"); + const [dateRange, setDateRange] = useState("day"); const monitorDetailsUrl = useMemo(() => { if (!monitorId) { diff --git a/client/src/Pages/Uptime/Details/index.tsx b/client/src/Pages/Uptime/Details/index.tsx index bcf7177ac..13998aed5 100644 --- a/client/src/Pages/Uptime/Details/index.tsx +++ b/client/src/Pages/Uptime/Details/index.tsx @@ -31,6 +31,7 @@ import type { RootState } from "@/Types/state"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { t } from "i18next"; import { Typography } from "@mui/material"; +import type { DateRange } from "@/Types/Query"; const certificateDateFormat = "MMM D, YYYY h A"; @@ -53,7 +54,7 @@ const UptimeDetailsPage = () => { const [rowsPerPage, setRowsPerPage] = useState(5); const [geoPage, setGeoPage] = useState(0); const [geoRowsPerPage, setGeoRowsPerPage] = useState(5); - const [dateRange, setDateRange] = useState("recent"); + const [dateRange, setDateRange] = useState("recent"); const [selectedLocation, setSelectedLocation] = useState("NA"); const monitorDetailsUrl = useMemo(() => { diff --git a/client/src/Routes/index.tsx b/client/src/Routes/index.tsx index a3fccf378..37e49d1ee 100644 --- a/client/src/Routes/index.tsx +++ b/client/src/Routes/index.tsx @@ -23,7 +23,7 @@ import InfrastructureDetails from "@/Pages/Infrastructure/Details"; // Docker import Docker from "@/Pages/Docker/Monitors"; import DockerHostDetails from "@/Pages/Docker/HostDetails"; -import DockerDetails from "@/Pages/Docker/Details"; +import DockerDetails from "@/Pages/Docker/ContainerDetails"; // Checks import Checks from "@/Pages/Checks"; diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index 6e5ca29f8..419ff7736 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -139,11 +139,28 @@ export const DockerContainerStates = [ ] as const; export type DockerContainerState = (typeof DockerContainerStates)[number]; -// Mirrors DockerHealthStatuses in server/src/types/network.ts. export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const; export type DockerHealthStatus = (typeof DockerHealthStatuses)[number]; -// Mirrors DockerContainerInfo in server/src/types/network.ts. +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; + name?: string; + source: string; + destination: string; + mode: string; + rw: boolean; +} + export interface DockerContainerInfo { id: string; name: string; @@ -151,12 +168,14 @@ export interface DockerContainerInfo { state: DockerContainerState; status: string; health: DockerHealthStatus; - cpuPct?: number; // fraction of one core; exceeds 1 when using multiple cores + cpuPct?: number; memoryUsedBytes?: number; memoryLimitBytes?: number; - memoryPct?: number; // 0-1 fraction of the memory limit + memoryPct?: number; restartCount?: number; startedAt?: string; + ports?: DockerContainerPort[]; + mounts?: DockerContainerMount[]; } export interface Check { diff --git a/client/src/Types/Query.ts b/client/src/Types/Query.ts new file mode 100644 index 000000000..6c0b819b9 --- /dev/null +++ b/client/src/Types/Query.ts @@ -0,0 +1,2 @@ +export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const; +export type DateRange = (typeof DateRanges)[number]; diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index ef48c2223..3a7778b75 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -1,7 +1,11 @@ import type { MonitorStatus, MonitorType } from "@/Types/Monitor"; import type { PaletteKey } from "@/Utils/Theme/Theme"; import type { ValueType } from "@/Components/design-elements/StatusLabel"; -import type { DockerContainerState } from "@/Types/Check"; +import type { + DockerContainerMount, + DockerContainerPort, + DockerContainerState, +} from "@/Types/Check"; export const getMonitorPath = (type: MonitorType): string => { const pathMap: Record = { @@ -111,3 +115,32 @@ export const formatUrl = (url: string, maxLength: number = 55) => { ? `${strippedUrl.slice(0, maxLength)}…` : strippedUrl; }; + +export const dedupeDockerPorts = ( + ports: DockerContainerPort[] +): DockerContainerPort[] => { + return ports.filter((port) => { + // Discard ipv6 wildcards + if (port.hostIp !== "::") return port; + + const isDuplicate = ports.some((otherPort) => { + if (otherPort.hostIp !== "0.0.0.0") return false; + + return ( + otherPort.privatePort === port.privatePort && + otherPort.protocol === port.protocol && + otherPort.publicPort === port.publicPort + ); + }); + return !isDuplicate; + }); +}; + +const isAnonymousVolumeName = (name: string): boolean => /^[0-9a-f]{64}$/.test(name); + +export const getDockerMountLabel = (mount: DockerContainerMount) => { + if (mount.type === "volume" && mount.name) + return isAnonymousVolumeName(mount.name) ? mount.name.slice(0, 12) : mount.name; + if (mount.source) return mount.source; + return mount.type; +}; diff --git a/server/openapi.json b/server/openapi.json index cd6a3466d..cf058ec4c 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -7295,6 +7295,55 @@ }, "startedAt": { "type": "string" + }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "privatePort": { + "type": "number" + }, + "protocol": { + "type": "string", + "enum": ["tcp", "udp", "sctp"] + }, + "publicPort": { + "type": "number" + }, + "hostIp": { + "type": "string" + } + }, + "required": ["privatePort", "protocol"] + } + }, + "mounts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "source": { + "type": "string" + }, + "destination": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "rw": { + "type": "boolean" + } + }, + "required": ["type", "source", "destination", "mode", "rw"] + } } }, "required": ["id", "name", "image", "state", "status", "health"] @@ -7792,6 +7841,55 @@ }, "startedAt": { "type": "string" + }, + "ports": { + "type": "array", + "items": { + "type": "object", + "properties": { + "privatePort": { + "type": "number" + }, + "protocol": { + "type": "string", + "enum": ["tcp", "udp", "sctp"] + }, + "publicPort": { + "type": "number" + }, + "hostIp": { + "type": "string" + } + }, + "required": ["privatePort", "protocol"] + } + }, + "mounts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "source": { + "type": "string" + }, + "destination": { + "type": "string" + }, + "mode": { + "type": "string" + }, + "rw": { + "type": "boolean" + } + }, + "required": ["type", "source", "destination", "mode", "rw"] + } } }, "required": ["id", "name", "image", "state", "status", "health"] diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 3b94f6989..2d3a16c49 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -13,7 +13,7 @@ import { ProxyModes, } from "@/domain/monitors/monitor.type.js"; import { DateRanges, SortOrders } from "@/types/query.js"; -import { DockerContainerStates, DockerHealthStatuses } from "@/types/network.js"; +import { DockerContainerStates, DockerHealthStatuses, DockerPortProtocols } from "@/types/network.js"; const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" }); @@ -430,6 +430,23 @@ export const uptimeDetailsResponseSchema = z.object({ monitorStats: monitorStatsResponseSchema.nullable(), }); +// Keep aligned with DockerContainerPort / DockerContainerMount in types/network.ts. +export const dockerContainerPortResponseSchema = z.object({ + privatePort: z.number(), + protocol: z.enum(DockerPortProtocols), + publicPort: z.number().optional(), + hostIp: z.string().optional(), +}); + +export const dockerContainerMountResponseSchema = z.object({ + type: z.string(), + name: z.string().optional(), + source: z.string(), + destination: z.string(), + mode: z.string(), + rw: z.boolean(), +}); + // Keep aligned with DockerContainerInfo / DockerContainerSummary in types/network.ts. export const dockerContainerResponseSchema = z.object({ id: z.string(), @@ -444,6 +461,8 @@ export const dockerContainerResponseSchema = z.object({ memoryPct: z.number().optional(), restartCount: z.number().optional(), startedAt: z.string().optional(), + ports: z.array(dockerContainerPortResponseSchema).optional(), + mounts: z.array(dockerContainerMountResponseSchema).optional(), }); export const containerSummaryResponseSchema = z.object({ diff --git a/server/src/domain/checks/check.model.ts b/server/src/domain/checks/check.model.ts index a101375f4..cdab5fec7 100644 --- a/server/src/domain/checks/check.model.ts +++ b/server/src/domain/checks/check.model.ts @@ -14,7 +14,15 @@ import type { GotTimings, ILighthouseAudit, } from "@/domain/checks/check.type.js"; -import { DockerContainerInfo, DockerContainerStates, DockerContainerSummary, DockerHealthStatuses } from "@/types/network.js"; +import { + DockerContainerInfo, + DockerContainerMount, + DockerContainerPort, + DockerContainerStates, + DockerContainerSummary, + DockerHealthStatuses, + DockerPortProtocols, +} from "@/types/network.js"; type CheckMetadataDocument = Omit & { monitorId: Types.ObjectId; @@ -173,6 +181,28 @@ const auditsSchema = new Schema( { _id: false } ); +const dockerContainerPortSchema = new Schema( + { + privatePort: { type: Number, required: true }, + protocol: { type: String, enum: DockerPortProtocols, default: "tcp" }, + publicPort: { type: Number }, + hostIp: { type: String }, + }, + { _id: false } +); + +const dockerContainerMountSchema = new Schema( + { + type: { type: String, default: "" }, + name: { type: String }, + source: { type: String, default: "" }, + destination: { type: String, default: "" }, + mode: { type: String, default: "" }, + rw: { type: Boolean, default: true }, + }, + { _id: false } +); + const dockerContainerSchema = new Schema( { id: { type: String, required: true }, @@ -187,6 +217,8 @@ const dockerContainerSchema = new Schema( memoryPct: { type: Number }, restartCount: { type: Number }, startedAt: { type: String }, + ports: { type: [dockerContainerPortSchema], default: undefined }, + mounts: { type: [dockerContainerMountSchema], default: undefined }, }, { _id: false } ); diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 152729f10..8d515b604 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -1,11 +1,15 @@ import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { DockerContainerInfo, + DockerContainerMount, + DockerContainerPort, DockerContainerState, DockerContainerStates, DockerContainerSummary, DockerHealthStatus, DockerHealthStatuses, + DockerPortProtocol, + DockerPortProtocols, DockerStatusPayload, MonitorStatusResponse, } from "@/types/network.js"; @@ -135,6 +139,8 @@ export class DockerProvider implements IStatusProvider { info.restartCount = inspectResult.value.RestartCount; info.startedAt = inspectResult.value.State?.StartedAt; info.health = this.toHealthStatus(inspectResult.value.State?.Health?.Status); + info.ports = this.toPorts(inspectResult.value.NetworkSettings?.Ports); + info.mounts = this.toMounts(inspectResult.value.Mounts); } else { this.logger.warn({ message: `Failed to inspect container ${info.name}`, @@ -152,6 +158,46 @@ export class DockerProvider implements IStatusProvider { return info; } + private toPortProtocol(protocol: string): DockerPortProtocol { + return (DockerPortProtocols as readonly string[]).includes(protocol) ? (protocol as DockerPortProtocol) : "tcp"; + } + + private toPorts(ports: Dockerode.ContainerInspectInfo["NetworkSettings"]["Ports"] | undefined): DockerContainerPort[] { + const result: DockerContainerPort[] = []; + for (const [portAndProtocol, bindings] of Object.entries(ports ?? {})) { + const [portString, protocolString = ""] = portAndProtocol.split("/"); + const privatePort = Number(portString); + if (!Number.isInteger(privatePort)) continue; + const protocol = this.toPortProtocol(protocolString); + // Exposed but unpublished ports have no bindings + if (!bindings || bindings.length === 0) { + result.push({ privatePort, protocol }); + continue; + } + for (const binding of bindings) { + const publicPort = Number(binding.HostPort); + result.push({ + privatePort, + protocol, + publicPort: Number.isInteger(publicPort) ? publicPort : undefined, + hostIp: binding.HostIp || undefined, + }); + } + } + return result; + } + + private toMounts(mounts: Dockerode.ContainerInspectInfo["Mounts"] | undefined): DockerContainerMount[] { + return (mounts ?? []).map((mount) => ({ + type: mount.Type ?? "", + name: mount.Name, + source: mount.Source ?? "", + destination: mount.Destination ?? "", + mode: mount.Mode ?? "", + rw: mount.RW ?? true, + })); + } + handle = async (monitor: Monitor): Promise> => { try { const docker = new this.DockerLib(this.toDockerOptions(monitor)); diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 25ee220ac..ae1cc78d2 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -123,6 +123,27 @@ export interface DockerContainerInfo { 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 { diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts index 74ca2d8db..4a9e3fe52 100644 --- a/server/test/unit/providers/network/dockerProvider.test.ts +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -375,6 +375,89 @@ describe("DockerProvider", () => { }); }); + // ── Ports and mounts ───────────────────────────────────────────────── + + describe("ports and mounts", () => { + it("maps published and unpublished ports and mounts from inspect", async () => { + const inspect = jest.fn().mockResolvedValue( + makeInspect({ + NetworkSettings: { + Ports: { + "80/tcp": [{ HostIp: "0.0.0.0", HostPort: "8080" }], + "443/tcp": null, + }, + }, + Mounts: [ + { + Type: "volume", + Name: "mongo_data", + Source: "/var/lib/docker/volumes/mongo_data/_data", + Destination: "/data/db", + Mode: "z", + RW: true, + }, + { Type: "bind", Source: "/srv/config", Destination: "/etc/app", Mode: "ro", RW: false }, + ], + }) + ); + const { provider } = setup({ inspect }); + + const result = await provider.handle(makeMonitor()); + + const container = result.payload?.containers[0]; + expect(container?.ports).toEqual([ + { privatePort: 80, protocol: "tcp", publicPort: 8080, hostIp: "0.0.0.0" }, + { privatePort: 443, protocol: "tcp" }, + ]); + expect(container?.mounts).toEqual([ + { type: "volume", name: "mongo_data", source: "/var/lib/docker/volumes/mongo_data/_data", destination: "/data/db", mode: "z", rw: true }, + { type: "bind", name: undefined, source: "/srv/config", destination: "/etc/app", mode: "ro", rw: false }, + ]); + }); + + it("emits one port entry per host binding", async () => { + const inspect = jest.fn().mockResolvedValue( + makeInspect({ + NetworkSettings: { + Ports: { + "80/tcp": [ + { HostIp: "0.0.0.0", HostPort: "8080" }, + { HostIp: "::", HostPort: "8080" }, + ], + }, + }, + }) + ); + const { provider } = setup({ inspect }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.containers[0]?.ports).toEqual([ + { privatePort: 80, protocol: "tcp", publicPort: 8080, hostIp: "0.0.0.0" }, + { privatePort: 80, protocol: "tcp", publicPort: 8080, hostIp: "::" }, + ]); + }); + + it("maps missing ports and mounts to empty arrays", async () => { + const { provider } = setup(); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.containers[0]?.ports).toEqual([]); + expect(result.payload?.containers[0]?.mounts).toEqual([]); + }); + + it("leaves ports and mounts undefined when inspect fails", async () => { + const inspect = jest.fn().mockRejectedValue(new Error("inspect failed")); + const { provider } = setup({ inspect }); + + const result = await provider.handle(makeMonitor()); + + expect(result.payload?.containers[0]?.ports).toBeUndefined(); + expect(result.payload?.containers[0]?.mounts).toBeUndefined(); + }); + }); + // ── Outer error handling ───────────────────────────────────────────── describe("outer error handling", () => { From cdd4953e030b00e6811929b890f9157f52ca59fc Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 2 Sep 2026 09:50:47 -0700 Subject: [PATCH 050/137] remove unused --- .../Docker/ContainerDetails/Components/StatBoxes.tsx | 3 --- .../src/Pages/Docker/ContainerDetails/TabOverview.tsx | 2 +- client/src/Pages/Docker/ContainerDetails/index.tsx | 11 +++++------ 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx b/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx index 79bf89e16..ed97917bf 100644 --- a/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx +++ b/client/src/Pages/Docker/ContainerDetails/Components/StatBoxes.tsx @@ -2,7 +2,6 @@ import Stack from "@mui/material/Stack"; import { StatBox } from "@/Components/design-elements"; // Types -import type { Monitor } from "@/Types/Monitor"; // Hooks import { useTheme } from "@mui/material"; @@ -13,10 +12,8 @@ import type { DockerContainerInfo } from "@/Types/Check"; import { formatDuration } from "@/Utils/TimeUtils"; export const DockerContainerStatusBoxes = ({ - monitor, container, }: { - monitor: Monitor | undefined; container: DockerContainerInfo | undefined; }) => { const { t } = useTranslation(); diff --git a/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx b/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx index 47503bff3..32ad93c2f 100644 --- a/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx +++ b/client/src/Pages/Docker/ContainerDetails/TabOverview.tsx @@ -12,7 +12,7 @@ import useMediaQuery from "@mui/material/useMediaQuery"; import type { Theme } from "@mui/material"; import type { TFunction } from "i18next"; import type { DockerContainerStats } from "@/Types/Monitor"; -import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; +import { LAYOUT } from "@/Utils/Theme/constants"; import { formatPercentage } from "@/Utils/FormatUtils"; import prettyBytes from "pretty-bytes"; import { BaseBox } from "@/Components/design-elements"; diff --git a/client/src/Pages/Docker/ContainerDetails/index.tsx b/client/src/Pages/Docker/ContainerDetails/index.tsx index e3462d86e..35faef4ee 100644 --- a/client/src/Pages/Docker/ContainerDetails/index.tsx +++ b/client/src/Pages/Docker/ContainerDetails/index.tsx @@ -34,12 +34,11 @@ const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) = ? `/monitors/docker/details/${monitorId}/containers/${containerName}?dateRange=${dateRange}` : null; - const { data: containerData, refetch: refetchContainer } = - useGet( - containerDetailsUrl, - {}, - { refreshInterval: 10000, keepPreviousData: true } - ); + const { data: containerData } = useGet( + containerDetailsUrl, + {}, + { refreshInterval: 10000, keepPreviousData: true } + ); const monitor = containerData?.monitor; if (!monitor) return null; From 5657d39589ccb03572a6f850bb5007720189c405 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 2 Sep 2026 09:52:36 -0700 Subject: [PATCH 051/137] remove unused prop --- client/src/Pages/Docker/ContainerDetails/index.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/client/src/Pages/Docker/ContainerDetails/index.tsx b/client/src/Pages/Docker/ContainerDetails/index.tsx index 35faef4ee..b4583e4c1 100644 --- a/client/src/Pages/Docker/ContainerDetails/index.tsx +++ b/client/src/Pages/Docker/ContainerDetails/index.tsx @@ -57,10 +57,7 @@ const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) = name={name} image={image} /> - + Date: Wed, 2 Sep 2026 10:00:37 -0700 Subject: [PATCH 052/137] add docker log/payload types --- server/src/types/network.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/server/src/types/network.ts b/server/src/types/network.ts index ae1cc78d2..49df868d2 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -103,6 +103,20 @@ export type DockerContainerState = (typeof DockerContainerStates)[number]; export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const; export type DockerHealthStatus = (typeof DockerHealthStatuses)[number]; +export const DockerLogStreams = ["stdout", "stderr"] as const; +export type DockerLogStream = (typeof DockerLogStreams)[number]; +export interface DockerLogLine { + ts: string; + stream: DockerLogStream; + text: string; +} + +export interface DockerContainerLogs { + containerId: string; + containerName: string; + lines: DockerLogLine[]; +} + export interface DockerContainerSummary { total: number; running: number; @@ -149,6 +163,7 @@ export interface DockerContainerMount { export interface DockerStatusPayload { containers: DockerContainerInfo[]; summary: DockerContainerSummary; + logs?: DockerContainerLogs[]; } export interface PortStatusPayload { From efcc7829d4d85e4be8958393736a3461e5806c3e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 2 Sep 2026 10:08:48 -0700 Subject: [PATCH 053/137] move docker types to docker domain --- .../src/api/validation/monitorValidation.ts | 2 +- server/src/domain/checks/check.model.ts | 2 +- server/src/domain/checks/check.snapshot.ts | 2 +- server/src/domain/checks/check.type.ts | 2 +- server/src/domain/docker/docker.type.ts | 65 +++++++++++++++++++ server/src/service/network/DockerProvider.ts | 6 +- server/src/types/network.ts | 63 +----------------- 7 files changed, 73 insertions(+), 69 deletions(-) create mode 100644 server/src/domain/docker/docker.type.ts diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 2d3a16c49..09f85401c 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -13,7 +13,7 @@ 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, DockerPortProtocols } from "@/domain/docker/docker.type.js"; const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" }); 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.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/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 8d515b604..7b6c98e7c 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -1,4 +1,6 @@ import { IStatusProvider } from "@/service/network/IStatusProvider.js"; +import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; + import { DockerContainerInfo, DockerContainerMount, @@ -10,9 +12,7 @@ import { DockerHealthStatuses, 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"; diff --git a/server/src/types/network.ts b/server/src/types/network.ts index 49df868d2..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,68 +98,6 @@ 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 const DockerLogStreams = ["stdout", "stderr"] as const; -export type DockerLogStream = (typeof DockerLogStreams)[number]; -export interface DockerLogLine { - ts: string; - stream: DockerLogStream; - text: string; -} - -export interface DockerContainerLogs { - containerId: string; - containerName: string; - lines: DockerLogLine[]; -} - -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[]; From 3e06cd39b10bb350eb1bb6a136ad34e525dff7f6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 2 Sep 2026 11:09:38 -0700 Subject: [PATCH 054/137] add docker logs to provider --- server/src/service/network/DockerProvider.ts | 87 +++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 7b6c98e7c..4e0d5e921 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -2,7 +2,9 @@ import { IStatusProvider } from "@/service/network/IStatusProvider.js"; import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import { + DOCKER_LOG_TAIL_LINES, DockerContainerInfo, + DockerContainerLogs, DockerContainerMount, DockerContainerPort, DockerContainerState, @@ -10,6 +12,8 @@ import { DockerContainerSummary, DockerHealthStatus, DockerHealthStatuses, + DockerLogLine, + DockerLogStream, DockerPortProtocol, DockerPortProtocols, } from "@/domain/docker/docker.type.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; @@ -119,10 +126,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 +209,76 @@ 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; + } + }; + handle = async (monitor: Monitor): Promise> => { try { const docker = new this.DockerLib(this.toDockerOptions(monitor)); @@ -226,6 +307,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 rawLogs = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerLogs(docker, s)); + const logs = rawLogs.filter((log) => log !== null); const summary: DockerContainerSummary = { total: containers.length, running: containers.filter((c) => c.state === "running").length, @@ -240,7 +323,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; From 6ea4ca1aa939e7787513f790d544590ee7476f64 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 2 Sep 2026 11:56:18 -0700 Subject: [PATCH 055/137] new docker-log domain,service, repo --- server/src/domain/docker/docker-log.model.ts | 60 ++++++++++++ .../docker/docker-log.repository.interface.ts | 10 ++ .../docker/docker-log.repository.mongo.ts | 93 ++++++++++++++++++ .../src/domain/docker/docker-log.service.ts | 97 +++++++++++++++++++ server/src/domain/docker/docker-log.type.ts | 28 ++++++ 5 files changed, 288 insertions(+) create mode 100644 server/src/domain/docker/docker-log.model.ts create mode 100644 server/src/domain/docker/docker-log.repository.interface.ts create mode 100644 server/src/domain/docker/docker-log.repository.mongo.ts create mode 100644 server/src/domain/docker/docker-log.service.ts create mode 100644 server/src/domain/docker/docker-log.type.ts 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..59745ba4a --- /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; 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..bbe9ec002 --- /dev/null +++ b/server/src/domain/docker/docker-log.repository.mongo.ts @@ -0,0 +1,93 @@ +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, + limit, + }: { + monitorId: string; + containerName: string; + before?: Date; + limit: number; + }): Promise => { + const docs = await DockerLogModel.find({ + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + "metadata.containerName": containerName, + ...(before ? { checkedAt: { $lt: before } } : {}), + }) + .sort({ checkedAt: -1 }) + .limit(limit) + .lean(); + return docs.map(this.toEntity); + }; + + 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; +} 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..0f914dde3 --- /dev/null +++ b/server/src/domain/docker/docker-log.service.ts @@ -0,0 +1,97 @@ +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 DockerLogsSerivce 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, + 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; +} From af465766c8e6ed2ebf9f4f2bf5bd4f29b7d907e6 Mon Sep 17 00:00:00 2001 From: eegrok <304507+eegrok@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:13:32 +1200 Subject: [PATCH 056/137] fix Start Time description to accurately reflect that the time zone is local time, not UTC --- client/src/locales/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index cbebadded..44eae42c1 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1217,7 +1217,7 @@ }, "startTime": { "title": "Start time", - "description": "Set the start time and duration for your maintenance window. All values are in UTC", + "description": "Set the start time and duration for your maintenance window. All values are in your local time zone.", "option": { "duration": { "label": "Duration" From db4689207aa6e74b4b66c5927d98ff8006651a03 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Tue, 1 Sep 2026 22:35:46 -0400 Subject: [PATCH 057/137] fix(client): make check error messages selectable and copyable Closes #3907. Selecting the text of a failed check's error message navigated away before the selection could be copied. The mouseup that ends a drag-select lands on the table row, which fires the row's onClick. Two changes: - DataTable ignores a row click when the user has an active text selection, so drag-selecting text inside any row no longer navigates. This applies to all eleven tables that pass onRowClick, not just checks. - The message column gets an explicit copy button, which is the more discoverable of the two fixes the reporter suggested. Added as a shared CopyButton in design-elements so other columns can reuse it. Note the row click on the uptime details checks table navigates to /checks/, but that route's parameter is :monitorId. A check id never matches a monitor, so the destination page renders empty - which is the "all this information disappears" in the report. Left alone here to keep this change reviewable; it needs its own decision about what that row should link to. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- .../Components/design-elements/CopyButton.tsx | 64 +++++++++++++++++++ .../src/Components/design-elements/Table.tsx | 14 +++- .../src/Components/design-elements/index.tsx | 1 + .../Pages/Checks/Components/ChecksTable.tsx | 21 +++++- .../Uptime/Details/Components/ChecksTable.tsx | 21 +++++- client/src/locales/en.json | 4 ++ 6 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 client/src/Components/design-elements/CopyButton.tsx diff --git a/client/src/Components/design-elements/CopyButton.tsx b/client/src/Components/design-elements/CopyButton.tsx new file mode 100644 index 000000000..caa317598 --- /dev/null +++ b/client/src/Components/design-elements/CopyButton.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +import IconButton from "@mui/material/IconButton"; +import Tooltip from "@mui/material/Tooltip"; +import { useTheme } from "@mui/material/styles"; +import { Check, Copy } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useToast } from "@/Hooks/UseToast"; + +const RESET_DELAY_MS = 1500; + +/** + * Copies `value` to the clipboard. Shows a tick for a moment so the click is + * acknowledged without moving the user anywhere. + */ +const CopyButton = ({ value, label }: { value: string; label?: string }) => { + const theme = useTheme(); + const { t } = useTranslation(); + const { toastError } = useToast(); + const [copied, setCopied] = useState(false); + + const handleCopy = async (e: React.MouseEvent) => { + // The button usually sits inside a clickable row. + e.stopPropagation(); + + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), RESET_DELAY_MS); + } catch { + toastError(t("common.copyFailed")); + } + }; + + const title = copied ? t("common.copied") : (label ?? t("common.copy")); + + return ( + + + {copied ? ( + + ) : ( + + )} + + + ); +}; + +export default CopyButton; diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index df0d52b16..c7bdad9f8 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -79,6 +79,14 @@ export function DataTable< }; const isSmall = useMediaQuery(theme.breakpoints.down("md")); + + // Selecting text inside a row ends in a mouseup on that row, which would + // otherwise navigate away and take the text with it. Ignore the click when + // the user has actually selected something. + const hasTextSelection = () => { + const selection = window.getSelection(); + return Boolean(selection && !selection.isCollapsed && selection.toString().trim()); + }; const isInteractive = Boolean(onRowClick) || expandableRows; if (data.length === 0 || headers.length === 0) { @@ -100,7 +108,10 @@ export function DataTable< keys.push(key); return ( (onRowClick ? onRowClick(row) : null)} + onClick={() => { + if (hasTextSelection()) return; + if (onRowClick) onRowClick(row); + }} spacing={theme.spacing(LAYOUT.XS)} sx={{ borderStyle: "solid", @@ -247,6 +258,7 @@ export function DataTable< ...(getRowSx?.(row) as object), }} onClick={() => { + if (hasTextSelection()) return; if (expandableRows) handleExpand(row); else if (onRowClick) onRowClick(row); }} diff --git a/client/src/Components/design-elements/index.tsx b/client/src/Components/design-elements/index.tsx index d7d38d014..e35d95823 100644 --- a/client/src/Components/design-elements/index.tsx +++ b/client/src/Components/design-elements/index.tsx @@ -28,3 +28,4 @@ export * from "./Avatar"; export * from "./StatusCodeLabel"; export * from "./StrategyBadge"; export * from "./StepProgress"; +export { default as CopyButton } from "./CopyButton"; diff --git a/client/src/Pages/Checks/Components/ChecksTable.tsx b/client/src/Pages/Checks/Components/ChecksTable.tsx index 31737f4bb..0e75116dc 100644 --- a/client/src/Pages/Checks/Components/ChecksTable.tsx +++ b/client/src/Pages/Checks/Components/ChecksTable.tsx @@ -3,8 +3,12 @@ import { Pagination, StatusLabel, StatusCodeLabel, + CopyButton, } from "@/Components/design-elements"; import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import { useTheme } from "@mui/material/styles"; +import { LAYOUT } from "@/Utils/Theme/constants"; import type { Header } from "@/Components/design-elements/Table"; import type { Monitor } from "@/Types/Monitor"; import { useTranslation } from "react-i18next"; @@ -31,6 +35,7 @@ export const ChecksTable = ({ setRowsPerPage: (rowsPerPage: number) => void; }) => { const { t } = useTranslation(); + const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const getHeaders = () => { @@ -79,7 +84,21 @@ export const ChecksTable = ({ id: "message", content: t("common.table.headers.message"), render: (row) => { - return row.message || "N/A"; + if (!row.message) return "N/A"; + return ( + + {row.message} + + + ); }, }, ]; diff --git a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx index b100c28c4..91b4ce588 100644 --- a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx @@ -3,8 +3,12 @@ import { Pagination, StatusLabel, StatusCodeLabel, + CopyButton, } from "@/Components/design-elements"; import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import { useTheme } from "@mui/material/styles"; +import { LAYOUT } from "@/Utils/Theme/constants"; import type { Header } from "@/Components/design-elements"; import type { Check } from "@/Types/Check"; @@ -31,6 +35,7 @@ export const ChecksTable = ({ }) => { const navigate = useNavigate(); const { t } = useTranslation(); + const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const headers: Header[] = [ @@ -52,7 +57,21 @@ export const ChecksTable = ({ id: "message", content: t("common.table.headers.message"), render: (row) => { - return row.message || "N/A"; + if (!row.message) return "N/A"; + return ( + + {row.message} + + + ); }, }, { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index cbebadded..9b193ef1f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -14,6 +14,10 @@ } }, "appName": "Checkmate", + "copy": "Copy", + "copied": "Copied", + "copyMessage": "Copy message", + "copyFailed": "Could not copy to clipboard", "statusCode": { "down": "Down", "networkError": { From 84a1b994a147197b7e0f5e4e806c142a0c9d3f91 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Tue, 1 Sep 2026 22:40:29 -0400 Subject: [PATCH 058/137] fix(client): address review feedback on the copy-message change Four issues from code review: - The selection guard read the document-wide selection, so a leftover selection anywhere on the page could swallow the first click on any row. It now checks that the selection is inside the row that was clicked, with the event threaded through to both call sites. - CopyButton's reset timer was never cleared, so it fired against an unmounted component when a row went away on pagination, and a rapid second click could not cancel the pending reset. Held in a ref and cleared on unmount and on re-copy. - navigator.clipboard only exists on secure origins, and the README documents reaching Checkmate over plain HTTP on a LAN address. There the API is undefined, so every click showed "could not copy" with no way forward. Falls back to a hidden textarea. - The two message cells were byte-identical, so they are now one shared CopyableText component. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- .../Components/design-elements/CopyButton.tsx | 36 ++++++++++++++-- .../design-elements/CopyableText.tsx | 41 +++++++++++++++++++ .../src/Components/design-elements/Table.tsx | 25 +++++++---- .../src/Components/design-elements/index.tsx | 1 + .../Pages/Checks/Components/ChecksTable.tsx | 22 +--------- .../Uptime/Details/Components/ChecksTable.tsx | 22 +--------- 6 files changed, 97 insertions(+), 50 deletions(-) create mode 100644 client/src/Components/design-elements/CopyableText.tsx diff --git a/client/src/Components/design-elements/CopyButton.tsx b/client/src/Components/design-elements/CopyButton.tsx index caa317598..5d45b9739 100644 --- a/client/src/Components/design-elements/CopyButton.tsx +++ b/client/src/Components/design-elements/CopyButton.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import IconButton from "@mui/material/IconButton"; import Tooltip from "@mui/material/Tooltip"; import { useTheme } from "@mui/material/styles"; @@ -8,6 +8,31 @@ import { useToast } from "@/Hooks/UseToast"; const RESET_DELAY_MS = 1500; +/** + * `navigator.clipboard` only exists on secure origins, and Checkmate is often + * self-hosted over plain HTTP on a LAN address. Fall back to a hidden textarea + * there rather than leaving the button permanently broken. + */ +const copyText = async (value: string) => { + if (navigator.clipboard?.writeText) { + return navigator.clipboard.writeText(value); + } + + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + + try { + if (!document.execCommand("copy")) throw new Error("Copy command was rejected"); + } finally { + document.body.removeChild(textarea); + } +}; + /** * Copies `value` to the clipboard. Shows a tick for a moment so the click is * acknowledged without moving the user anywhere. @@ -17,15 +42,20 @@ const CopyButton = ({ value, label }: { value: string; label?: string }) => { const { t } = useTranslation(); const { toastError } = useToast(); const [copied, setCopied] = useState(false); + const resetTimer = useRef>(undefined); + + // These rows unmount on pagination, so the tick timer has to be cancelled. + useEffect(() => () => clearTimeout(resetTimer.current), []); const handleCopy = async (e: React.MouseEvent) => { // The button usually sits inside a clickable row. e.stopPropagation(); try { - await navigator.clipboard.writeText(value); + await copyText(value); setCopied(true); - setTimeout(() => setCopied(false), RESET_DELAY_MS); + clearTimeout(resetTimer.current); + resetTimer.current = setTimeout(() => setCopied(false), RESET_DELAY_MS); } catch { toastError(t("common.copyFailed")); } diff --git a/client/src/Components/design-elements/CopyableText.tsx b/client/src/Components/design-elements/CopyableText.tsx new file mode 100644 index 000000000..5d09e307b --- /dev/null +++ b/client/src/Components/design-elements/CopyableText.tsx @@ -0,0 +1,41 @@ +import Stack from "@mui/material/Stack"; +import { useTheme } from "@mui/material/styles"; +import { useTranslation } from "react-i18next"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import CopyButton from "./CopyButton"; + +/** + * Text alongside a copy button, for table cells holding values worth lifting + * out - error messages in particular. + */ +const CopyableText = ({ + value, + fallback = "N/A", + copyLabel, +}: { + value?: string | null; + fallback?: string; + copyLabel?: string; +}) => { + const theme = useTheme(); + const { t } = useTranslation(); + + if (!value) return <>{fallback}; + + return ( + + {value} + + + ); +}; + +export default CopyableText; diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index c7bdad9f8..8650f2bc6 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -82,10 +82,21 @@ export function DataTable< // Selecting text inside a row ends in a mouseup on that row, which would // otherwise navigate away and take the text with it. Ignore the click when - // the user has actually selected something. - const hasTextSelection = () => { + // the selection is inside the row that was clicked - a leftover selection + // elsewhere on the page must not swallow a legitimate click. + const hasTextSelectionWithin = (element: Element) => { const selection = window.getSelection(); - return Boolean(selection && !selection.isCollapsed && selection.toString().trim()); + if (!selection || selection.isCollapsed || !selection.toString().trim()) { + return false; + } + return ( + selection.anchorNode !== null && + element.contains( + selection.anchorNode.nodeType === Node.TEXT_NODE + ? selection.anchorNode.parentNode + : selection.anchorNode + ) + ); }; const isInteractive = Boolean(onRowClick) || expandableRows; @@ -108,8 +119,8 @@ export function DataTable< keys.push(key); return ( { - if (hasTextSelection()) return; + onClick={(e) => { + if (hasTextSelectionWithin(e.currentTarget)) return; if (onRowClick) onRowClick(row); }} spacing={theme.spacing(LAYOUT.XS)} @@ -257,8 +268,8 @@ export function DataTable< cursor: isInteractive ? "pointer" : "default", ...(getRowSx?.(row) as object), }} - onClick={() => { - if (hasTextSelection()) return; + onClick={(e) => { + if (hasTextSelectionWithin(e.currentTarget)) return; if (expandableRows) handleExpand(row); else if (onRowClick) onRowClick(row); }} diff --git a/client/src/Components/design-elements/index.tsx b/client/src/Components/design-elements/index.tsx index e35d95823..e1c1c3965 100644 --- a/client/src/Components/design-elements/index.tsx +++ b/client/src/Components/design-elements/index.tsx @@ -29,3 +29,4 @@ export * from "./StatusCodeLabel"; export * from "./StrategyBadge"; export * from "./StepProgress"; export { default as CopyButton } from "./CopyButton"; +export { default as CopyableText } from "./CopyableText"; diff --git a/client/src/Pages/Checks/Components/ChecksTable.tsx b/client/src/Pages/Checks/Components/ChecksTable.tsx index 0e75116dc..4d5eebbdf 100644 --- a/client/src/Pages/Checks/Components/ChecksTable.tsx +++ b/client/src/Pages/Checks/Components/ChecksTable.tsx @@ -3,12 +3,9 @@ import { Pagination, StatusLabel, StatusCodeLabel, - CopyButton, + CopyableText, } from "@/Components/design-elements"; import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import { useTheme } from "@mui/material/styles"; -import { LAYOUT } from "@/Utils/Theme/constants"; import type { Header } from "@/Components/design-elements/Table"; import type { Monitor } from "@/Types/Monitor"; import { useTranslation } from "react-i18next"; @@ -35,7 +32,6 @@ export const ChecksTable = ({ setRowsPerPage: (rowsPerPage: number) => void; }) => { const { t } = useTranslation(); - const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const getHeaders = () => { @@ -84,21 +80,7 @@ export const ChecksTable = ({ id: "message", content: t("common.table.headers.message"), render: (row) => { - if (!row.message) return "N/A"; - return ( - - {row.message} - - - ); + return ; }, }, ]; diff --git a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx index 91b4ce588..ae1d87d45 100644 --- a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx @@ -3,12 +3,9 @@ import { Pagination, StatusLabel, StatusCodeLabel, - CopyButton, + CopyableText, } from "@/Components/design-elements"; import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; -import { useTheme } from "@mui/material/styles"; -import { LAYOUT } from "@/Utils/Theme/constants"; import type { Header } from "@/Components/design-elements"; import type { Check } from "@/Types/Check"; @@ -35,7 +32,6 @@ export const ChecksTable = ({ }) => { const navigate = useNavigate(); const { t } = useTranslation(); - const theme = useTheme(); const uiTimezone = useSelector((state: RootState) => state.ui.timezone); const headers: Header[] = [ @@ -57,21 +53,7 @@ export const ChecksTable = ({ id: "message", content: t("common.table.headers.message"), render: (row) => { - if (!row.message) return "N/A"; - return ( - - {row.message} - - - ); + return ; }, }, { From 9c796edbde33b7c2ec71dc86875f9ba2639ba86d Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Wed, 2 Sep 2026 08:34:52 -0400 Subject: [PATCH 059/137] refactor(client): drop the copy button, keep the selection fix The selection guard alone closes #3907: drag-selecting a check's error message no longer navigates away, so the text can be copied. The copy button was a second, larger answer to the same problem, and it brought a reusable component, an HTTP clipboard fallback, four translation keys and a timer to maintain. Reverting it leaves the fix at ~29 lines in one file, and keeps the behaviour change to the thing that was actually broken. CopyButton is worth having for monitor URLs, tokens and incident ids, but it should land on its own merits rather than inside a bug fix. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- .../Components/design-elements/CopyButton.tsx | 94 ------------------- .../design-elements/CopyableText.tsx | 41 -------- .../src/Components/design-elements/index.tsx | 2 - .../Pages/Checks/Components/ChecksTable.tsx | 3 +- .../Uptime/Details/Components/ChecksTable.tsx | 3 +- client/src/locales/en.json | 4 - 6 files changed, 2 insertions(+), 145 deletions(-) delete mode 100644 client/src/Components/design-elements/CopyButton.tsx delete mode 100644 client/src/Components/design-elements/CopyableText.tsx diff --git a/client/src/Components/design-elements/CopyButton.tsx b/client/src/Components/design-elements/CopyButton.tsx deleted file mode 100644 index 5d45b9739..000000000 --- a/client/src/Components/design-elements/CopyButton.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import IconButton from "@mui/material/IconButton"; -import Tooltip from "@mui/material/Tooltip"; -import { useTheme } from "@mui/material/styles"; -import { Check, Copy } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { useToast } from "@/Hooks/UseToast"; - -const RESET_DELAY_MS = 1500; - -/** - * `navigator.clipboard` only exists on secure origins, and Checkmate is often - * self-hosted over plain HTTP on a LAN address. Fall back to a hidden textarea - * there rather than leaving the button permanently broken. - */ -const copyText = async (value: string) => { - if (navigator.clipboard?.writeText) { - return navigator.clipboard.writeText(value); - } - - const textarea = document.createElement("textarea"); - textarea.value = value; - textarea.setAttribute("readonly", ""); - textarea.style.position = "fixed"; - textarea.style.opacity = "0"; - document.body.appendChild(textarea); - textarea.select(); - - try { - if (!document.execCommand("copy")) throw new Error("Copy command was rejected"); - } finally { - document.body.removeChild(textarea); - } -}; - -/** - * Copies `value` to the clipboard. Shows a tick for a moment so the click is - * acknowledged without moving the user anywhere. - */ -const CopyButton = ({ value, label }: { value: string; label?: string }) => { - const theme = useTheme(); - const { t } = useTranslation(); - const { toastError } = useToast(); - const [copied, setCopied] = useState(false); - const resetTimer = useRef>(undefined); - - // These rows unmount on pagination, so the tick timer has to be cancelled. - useEffect(() => () => clearTimeout(resetTimer.current), []); - - const handleCopy = async (e: React.MouseEvent) => { - // The button usually sits inside a clickable row. - e.stopPropagation(); - - try { - await copyText(value); - setCopied(true); - clearTimeout(resetTimer.current); - resetTimer.current = setTimeout(() => setCopied(false), RESET_DELAY_MS); - } catch { - toastError(t("common.copyFailed")); - } - }; - - const title = copied ? t("common.copied") : (label ?? t("common.copy")); - - return ( - - - {copied ? ( - - ) : ( - - )} - - - ); -}; - -export default CopyButton; diff --git a/client/src/Components/design-elements/CopyableText.tsx b/client/src/Components/design-elements/CopyableText.tsx deleted file mode 100644 index 5d09e307b..000000000 --- a/client/src/Components/design-elements/CopyableText.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import Stack from "@mui/material/Stack"; -import { useTheme } from "@mui/material/styles"; -import { useTranslation } from "react-i18next"; -import { LAYOUT } from "@/Utils/Theme/constants"; -import CopyButton from "./CopyButton"; - -/** - * Text alongside a copy button, for table cells holding values worth lifting - * out - error messages in particular. - */ -const CopyableText = ({ - value, - fallback = "N/A", - copyLabel, -}: { - value?: string | null; - fallback?: string; - copyLabel?: string; -}) => { - const theme = useTheme(); - const { t } = useTranslation(); - - if (!value) return <>{fallback}; - - return ( - - {value} - - - ); -}; - -export default CopyableText; diff --git a/client/src/Components/design-elements/index.tsx b/client/src/Components/design-elements/index.tsx index e1c1c3965..d7d38d014 100644 --- a/client/src/Components/design-elements/index.tsx +++ b/client/src/Components/design-elements/index.tsx @@ -28,5 +28,3 @@ export * from "./Avatar"; export * from "./StatusCodeLabel"; export * from "./StrategyBadge"; export * from "./StepProgress"; -export { default as CopyButton } from "./CopyButton"; -export { default as CopyableText } from "./CopyableText"; diff --git a/client/src/Pages/Checks/Components/ChecksTable.tsx b/client/src/Pages/Checks/Components/ChecksTable.tsx index 4d5eebbdf..31737f4bb 100644 --- a/client/src/Pages/Checks/Components/ChecksTable.tsx +++ b/client/src/Pages/Checks/Components/ChecksTable.tsx @@ -3,7 +3,6 @@ import { Pagination, StatusLabel, StatusCodeLabel, - CopyableText, } from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements/Table"; @@ -80,7 +79,7 @@ export const ChecksTable = ({ id: "message", content: t("common.table.headers.message"), render: (row) => { - return ; + return row.message || "N/A"; }, }, ]; diff --git a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx index ae1d87d45..b100c28c4 100644 --- a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx @@ -3,7 +3,6 @@ import { Pagination, StatusLabel, StatusCodeLabel, - CopyableText, } from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements"; @@ -53,7 +52,7 @@ export const ChecksTable = ({ id: "message", content: t("common.table.headers.message"), render: (row) => { - return ; + return row.message || "N/A"; }, }, { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 9b193ef1f..cbebadded 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -14,10 +14,6 @@ } }, "appName": "Checkmate", - "copy": "Copy", - "copied": "Copied", - "copyMessage": "Copy message", - "copyFailed": "Could not copy to clipboard", "statusCode": { "down": "Down", "networkError": { From e69a2f05af46792c21c1e376ac1195e691375317 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 3 Sep 2026 00:45:40 -0400 Subject: [PATCH 060/137] feat(client): define the neutral palette and switch to Geist The palette declared a brand green and six chart hues but never defined the neutrals, so MUI's defaults filled the gap: body text at 60% black, dividers at 12%, and background.default equal to background.paper. That is the "washed out" everything sat on - table cells render through text.secondary, and cards had no ground to sit on. - Opaque neutral ramp, biased slightly green so it sits with the brand. background.default is now off-white so paper separates from the page. - Semantic success/error/warning derived from the brand instead of Material's stock red/green/orange. Only `main` is set: `light` is consumed as a saturated colour by status dots, histogram bars and heatmap cells, so MUI must keep deriving it. - Dark mode gets the same treatment; it defined background and secondary but its text and divider were still MUI defaults. - Geist Variable, self-hosted via @fontsource-variable so air-gapped installs keep the typeface. Base size stays at 13px. - h1 700 / h2 600 against 400 body, and table headers 500 -> 600. Two weights was why everything read at the same visual weight. All contrast pairs pass WCAG AA in both themes (lowest 5.98:1). No component markup changed. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/package-lock.json | 10 ++++ client/package.json | 1 + .../src/Components/design-elements/Table.tsx | 2 +- client/src/Utils/Theme/Palette.ts | 54 +++++++++++++++++++ client/src/Utils/Theme/Theme.ts | 8 +-- client/src/main.tsx | 2 + 6 files changed, 73 insertions(+), 4 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 6cee968e3..629678180 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11,6 +11,7 @@ "@csstools/css-tokenizer": "^3.0.4", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", + "@fontsource-variable/geist": "^5.3.0", "@hello-pangea/dnd": "^18.0.0", "@hookform/resolvers": "5.2.2", "@mui/lab": "7.0.0", @@ -950,6 +951,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fontsource-variable/geist": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", + "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "license": "BSD-3-Clause" diff --git a/client/package.json b/client/package.json index ce2e481ba..a26499805 100644 --- a/client/package.json +++ b/client/package.json @@ -21,6 +21,7 @@ "@csstools/css-tokenizer": "^3.0.4", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", + "@fontsource-variable/geist": "^5.3.0", "@hello-pangea/dnd": "^18.0.0", "@hookform/resolvers": "5.2.2", "@mui/lab": "7.0.0", diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index df0d52b16..9ef7dcee8 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -197,7 +197,7 @@ export function DataTable< "& :is(th)": { backgroundColor: theme.palette.background.paper, color: theme.palette.text.secondary, - fontWeight: 500, + fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.08em", padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`, diff --git a/client/src/Utils/Theme/Palette.ts b/client/src/Utils/Theme/Palette.ts index 0d9168f8a..bfb9dda5d 100644 --- a/client/src/Utils/Theme/Palette.ts +++ b/client/src/Utils/Theme/Palette.ts @@ -18,6 +18,23 @@ export const colors = { gray700: "#313131", gray900: "#151518", gray850: "#1c1c21", + // Neutral ramp, biased slightly green so it sits with the brand rather than + // against it. Opaque values - alpha-black over white reads washed out. + ink900: "#101917", + ink600: "#55635C", + ink400: "#7C8983", + surface50: "#F6F8F7", + rule200: "#D9E1DD", + // Semantic colours derived from the brand rather than Material's defaults + successMain: "#0D6049", + errorMain: "#A02B24", + warningMain: "#8C5806", + inkDark900: "#ECEFED", + inkDark600: "#A3ADA8", + ruleDark: "#2C3532", + successDark: "#4BC49E", + errorDark: "#E8837A", + warningDark: "#E0A458", brandGreen: "#13715B", brandGreenLight: "#4DAF94", green200: "#ECF7F2", @@ -47,6 +64,28 @@ export const lightPalette = { secondary: { main: colors.gray200, }, + background: { + default: colors.surface50, + paper: "#FFFFFF", + }, + text: { + primary: colors.ink900, + secondary: colors.ink600, + disabled: colors.ink400, + }, + divider: colors.rule200, + // Only `main` is set. MUI derives `light`/`dark` from it, and `light` is + // consumed as a saturated colour - status dots, histogram bars, heatmap + // cells - so it must not be overridden with a pale tint. + success: { + main: colors.successMain, + }, + error: { + main: colors.errorMain, + }, + warning: { + main: colors.warningMain, + }, sidebar: { accent: colors.brandGreen, }, @@ -81,6 +120,21 @@ export const darkPalette = { default: colors.gray900, paper: colors.gray850, }, + text: { + primary: colors.inkDark900, + secondary: colors.inkDark600, + disabled: colors.ink400, + }, + divider: colors.ruleDark, + success: { + main: colors.successDark, + }, + error: { + main: colors.errorDark, + }, + warning: { + main: colors.warningDark, + }, sidebar: { accent: colors.brandGreenLight, }, diff --git a/client/src/Utils/Theme/Theme.ts b/client/src/Utils/Theme/Theme.ts index c63929a60..a0b1d75be 100644 --- a/client/src/Utils/Theme/Theme.ts +++ b/client/src/Utils/Theme/Theme.ts @@ -57,7 +57,7 @@ export type PaletteKey = { [K in keyof Theme["palette"]]: Theme["palette"][K] extends { main: any } ? K : never; }[keyof Theme["palette"]]; -const fontFamilyPrimary = "system-ui, sans-serif"; +const fontFamilyPrimary = '"Geist Variable", system-ui, sans-serif'; const fontFamilyMonospace = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace'; const shadow = @@ -85,11 +85,13 @@ export const theme = (mode: string, palette: any) => fontSize: typographyLevels.base, h1: { fontSize: typographyLevels.xxl, - fontWeight: 500, + fontWeight: 700, + letterSpacing: "-0.015em", }, h2: { fontSize: typographyLevels.l, - fontWeight: 400, + fontWeight: 600, + letterSpacing: "-0.01em", }, body1: { fontSize: typographyLevels.m, diff --git a/client/src/main.tsx b/client/src/main.tsx index 1f5d4d2ef..ca81c338c 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,5 +1,7 @@ import ReactDOM from "react-dom/client"; import App from "./App.jsx"; +// Self-hosted so the app keeps its typeface on air-gapped installs +import "@fontsource-variable/geist"; import "./index.css"; import { BrowserRouter as Router } from "react-router-dom"; import { Provider } from "react-redux"; From bdad9491703fa2b3a76dd4277b9a738269b41822 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 3 Sep 2026 00:48:02 -0400 Subject: [PATCH 061/137] fix(client): put the tab bar back on the 13px base Tabs.tsx hardcoded fontSize: 14, so the tab bar sat a pixel above the 13px base the rest of the chrome uses. Now typographyLevels.m, which also satisfies the no-hardcoded-literals rule. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Components/design-elements/Tabs.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/Components/design-elements/Tabs.tsx b/client/src/Components/design-elements/Tabs.tsx index 149c0bd93..ae9a1c512 100644 --- a/client/src/Components/design-elements/Tabs.tsx +++ b/client/src/Components/design-elements/Tabs.tsx @@ -1,6 +1,7 @@ import MuiTabs from "@mui/material/Tabs"; import type { TabsProps } from "@mui/material/Tabs"; import { useTheme } from "@mui/material/styles"; +import { typographyLevels } from "@/Utils/Theme/Palette"; interface CustomTabsProps extends TabsProps {} export const Tabs = (props: CustomTabsProps) => { @@ -38,7 +39,7 @@ export const Tab = (props: CustomTabProps) => { iconPosition="start" sx={{ textTransform: "none", - fontSize: 14, + fontSize: typographyLevels.m, fontWeight: 500, minHeight: 34, padding: theme.spacing(1, 0), From 1372e66749cd955e33e36364d3474e3d7c69535a Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 3 Sep 2026 00:52:41 -0400 Subject: [PATCH 062/137] fix(client): calm the stat box grid and firm up borders Three fixes, all fallout from giving `divider` a real value: - The graph-paper texture behind the status counts is drawn with `divider`. That used to be rgba(0,0,0,.12) and near-invisible; now it is opaque, so the grid jumped forward and read as the loudest thing on the page. It is texture, not a rule, so it now takes divider at 40%. - GradientBox called lighten() on background.paper to build its gradient. paper is white in light mode and lightening white is a no-op, so the gradient collapsed flat. Darkens slightly toward the page ground instead. - Borders were too faint at #D9E1DD (1.33:1 against paper). Now #C7D2CC (1.55:1), and dark mode #2C3532 -> #3A4642 (1.73:1), so tables and cards have a visible edge without turning into hard rules. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Components/design-elements/StatBox.tsx | 6 ++++-- client/src/Components/design-elements/StatusBox.tsx | 9 ++++++--- client/src/Utils/Theme/Palette.ts | 4 ++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/client/src/Components/design-elements/StatBox.tsx b/client/src/Components/design-elements/StatBox.tsx index 1543766fc..addbc7ee9 100644 --- a/client/src/Components/design-elements/StatBox.tsx +++ b/client/src/Components/design-elements/StatBox.tsx @@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import Box from "@mui/material/Box"; import { useTheme } from "@mui/material/styles"; -import { lighten } from "@mui/material/styles"; +import { darken, lighten } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import type { PaletteKey } from "@/Utils/Theme/Theme"; import { BaseBox, TooltipWithInfo } from "@/Components/design-elements"; @@ -19,7 +19,9 @@ export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isLight = theme.palette.mode === "light"; const paper = theme.palette.background.paper; - const paperStart = lighten(paper, isLight ? 0.06 : 0.08); + // In light mode `paper` is white, and lightening white is a no-op - the + // gradient collapses flat. Darken slightly towards the page ground instead. + const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08); const paperEnd = paper; const bg = palette ? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)` diff --git a/client/src/Components/design-elements/StatusBox.tsx b/client/src/Components/design-elements/StatusBox.tsx index 017873eb9..191ed21d6 100644 --- a/client/src/Components/design-elements/StatusBox.tsx +++ b/client/src/Components/design-elements/StatusBox.tsx @@ -5,7 +5,7 @@ import { BaseBox } from "@/Components/design-elements"; import { useTranslation } from "react-i18next"; import type { SxProps } from "@mui/material"; -import { useTheme } from "@mui/material/styles"; +import { alpha, useTheme } from "@mui/material/styles"; type StatusBoxProps = React.PropsWithChildren<{ children: React.ReactNode; @@ -33,9 +33,12 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => { bottom={0} sx={{ pointerEvents: "none", + // Deliberately far fainter than `divider` - this is texture + // behind the number, not a rule, and it must never compete + // with the box's own border. backgroundImage: ` - linear-gradient(${theme.palette.divider} 1px, transparent 1px), - linear-gradient(90deg, ${theme.palette.divider} 1px, transparent 1px) + linear-gradient(${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px), + linear-gradient(90deg, ${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px) `, backgroundSize: "24px 24px", maskImage: diff --git a/client/src/Utils/Theme/Palette.ts b/client/src/Utils/Theme/Palette.ts index bfb9dda5d..a7f4b9a27 100644 --- a/client/src/Utils/Theme/Palette.ts +++ b/client/src/Utils/Theme/Palette.ts @@ -24,14 +24,14 @@ export const colors = { ink600: "#55635C", ink400: "#7C8983", surface50: "#F6F8F7", - rule200: "#D9E1DD", + rule200: "#C7D2CC", // Semantic colours derived from the brand rather than Material's defaults successMain: "#0D6049", errorMain: "#A02B24", warningMain: "#8C5806", inkDark900: "#ECEFED", inkDark600: "#A3ADA8", - ruleDark: "#2C3532", + ruleDark: "#3A4642", successDark: "#4BC49E", errorDark: "#E8837A", warningDark: "#E0A458", From 8582958ad5e2f55cbba06cbc9b845e8b094f3def Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 3 Sep 2026 00:56:29 -0400 Subject: [PATCH 063/137] fix(client): rebuild the stat box gradient from `main` The coloured stat box ran its gradient from `.light` to `.main`. With Material's stock warning that was #ff9800 -> #ed6c02, a bright orange. The new warning.main is a dark amber chosen so the "Paused" chip text clears 4.5:1, and MUI derives `light` from it, so the gradient became brown mud. The two roles were never compatible: `main` has to be dark enough for small text on white, and the same value was being used as a large filled background. The gradient now runs from lighten(main, 0.12) to main, which keeps the box in the status colour, restores the depth that the flat brown lost, and holds white contrastText at 4.64:1 or better at the lightest point for all four palettes. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Components/design-elements/StatBox.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/Components/design-elements/StatBox.tsx b/client/src/Components/design-elements/StatBox.tsx index addbc7ee9..9cdb9c024 100644 --- a/client/src/Components/design-elements/StatBox.tsx +++ b/client/src/Components/design-elements/StatBox.tsx @@ -23,8 +23,11 @@ export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) // gradient collapses flat. Darken slightly towards the page ground instead. const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08); const paperEnd = paper; + // The gradient carries white `contrastText`, so it runs between two shades + // dark enough to keep that legible. `light` is a mid-tone used for status + // dots and chart marks - too pale here - so lift from `main` instead. const bg = palette - ? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)` + ? `linear-gradient(135deg, ${lighten(theme.palette[palette].main, 0.12)} 0%, ${theme.palette[palette].main} 100%)` : `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`; return ( From 62fb17f64c80d3e923ae5288bc244bed3d01ae0f Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 3 Sep 2026 01:00:23 -0400 Subject: [PATCH 064/137] fix(client): match header button icons and toggle weight to the rest of the UI Two mismatches in the monitor detail header: - The action button icons rendered at the shared Icon default of 20px, while the sidebar clamps the same lucide icons to 16px in CSS. The buttons were therefore 25% larger than every other icon in the app, and taller than the cap height of the 13px label beside them. Now 16px at all eight call sites. Left at full opacity - the sidebar dims to 0.81 because nav is recessive, but these are active controls. - ToggleButton went to fontWeight 500 when selected while Button is a flat 400, so the time range group sat heavier than the button row above it. Weight now stays at 400 and the tinted fill plus text.primary ink carry the selected state; that is a 2.9x difference in ink against the unselected label, so selection is still obvious. It also stops the label reflowing as you switch range. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Components/inputs/ToggleButton.tsx | 5 +- .../monitors/HeaderMonitorControls.tsx | 56 +++++++++++++++++-- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/client/src/Components/inputs/ToggleButton.tsx b/client/src/Components/inputs/ToggleButton.tsx index a2b392357..467c232b6 100644 --- a/client/src/Components/inputs/ToggleButton.tsx +++ b/client/src/Components/inputs/ToggleButton.tsx @@ -33,8 +33,11 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => { }, "&.Mui-selected": { backgroundColor: selectedBg, + // Weight stays at 400 so the group sits level with Button, + // which it is routinely placed beside. The fill and the + // darker ink carry the selected state instead - a weight + // change also reflows the label width as you switch. color: theme.palette.text.primary, - fontWeight: 500, "&:hover": { backgroundColor: selectedHoverBg, }, diff --git a/client/src/Components/monitors/HeaderMonitorControls.tsx b/client/src/Components/monitors/HeaderMonitorControls.tsx index 7f6968f0b..a32a33298 100644 --- a/client/src/Components/monitors/HeaderMonitorControls.tsx +++ b/client/src/Components/monitors/HeaderMonitorControls.tsx @@ -64,7 +64,12 @@ export const HeaderMonitorControls = ({ variant="contained" color="secondary" loading={isPosting} - startIcon={} + startIcon={ + + } onClick={async () => { await post(`/notifications/test/all`, { monitorId: monitor.id }); }} @@ -77,7 +82,12 @@ export const HeaderMonitorControls = ({ , but every
    paints background.paper over it, and the hover rule targets cells - so hover won and selection was invisible. Selection and hover are now both cell-level rules in DataTable, with selection first so hover layers on top, and rows carry an `is-selected` class from a new isRowSelected prop. Uptime, Docker and Infrastructure tables all had the same broken copy of this; they now share the one implementation, and any future table gets it for free. action.selected/selectedHover are defined explicitly and tinted with the brand, so a selected row reads as chosen rather than dirty. Kept low (6-11%) so the highlight does not swamp the row, especially in dark mode. From code review: - Dropped the Google Fonts @import at the top of index.css. Nothing has used Inter since the switch to Geist, and it was a render-blocking third-party request on every load - exactly what self-hosting the font was meant to avoid on air-gapped installs. - TextInput's autofill override painted background.default, which used to equal paper and no longer does, so Chrome-autofilled fields showed a grey block on white cards. Now paper. - Sidebar background moved from sx to the bgcolor prop (convention rule 1, and it was a new lint warning), and the backdropFilter dropped - the background is opaque, so the blur only cost a compositing layer. - Tab no longer bolds the selected label. That is the same reflow the ToggleButton change removed, and worse here because the indicator animates toward a target that moves as the label widens. - Corrected the StatBox gradient comment: contrastText is white in light mode but near-black in dark, so the constraint is staying near `main`, not staying dark. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- .../Components/design-elements/StatBox.tsx | 8 ++++--- .../src/Components/design-elements/Table.tsx | 21 ++++++++++++++++++- .../src/Components/design-elements/Tabs.tsx | 5 ++++- client/src/Components/inputs/TextInput.tsx | 2 +- client/src/Components/sidebar/index.tsx | 9 ++++---- .../Components/DockerMonitorsTable.tsx | 6 +----- .../Monitors/Components/MonitorsTable.tsx | 6 +----- .../Components/UptimeMonitorsTable.tsx | 6 +----- client/src/Utils/Theme/Palette.ts | 6 ++++++ client/src/Utils/Theme/Theme.ts | 1 + client/src/index.css | 2 -- 11 files changed, 44 insertions(+), 28 deletions(-) diff --git a/client/src/Components/design-elements/StatBox.tsx b/client/src/Components/design-elements/StatBox.tsx index 9cdb9c024..b5e9c7834 100644 --- a/client/src/Components/design-elements/StatBox.tsx +++ b/client/src/Components/design-elements/StatBox.tsx @@ -23,9 +23,11 @@ export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) // gradient collapses flat. Darken slightly towards the page ground instead. const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08); const paperEnd = paper; - // The gradient carries white `contrastText`, so it runs between two shades - // dark enough to keep that legible. `light` is a mid-tone used for status - // dots and chart marks - too pale here - so lift from `main` instead. + // Both ends stay close to `main` so MUI's derived `contrastText` still + // applies - that is white on the dark light-mode fills and near-black on + // the lighter dark-mode ones, so a bigger lift would break one or other. + // `light` is a mid-tone used for status dots and chart marks, too pale to + // use here, so the lift comes off `main` instead. const bg = palette ? `linear-gradient(135deg, ${lighten(theme.palette[palette].main, 0.12)} 0%, ${theme.palette[palette].main} 100%)` : `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`; diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index 9ef7dcee8..44eab9424 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -52,6 +52,8 @@ type DataTableProps = emptyViewText?: string; emptyViewPositive?: boolean; getRowSx?: (row: T) => SxProps; + /** Marks a row selected so it gets the shared selected + hover treatment. */ + isRowSelected?: (row: T) => boolean; }; export function DataTable< @@ -70,6 +72,7 @@ export function DataTable< emptyViewText, emptyViewPositive, getRowSx, + isRowSelected, }: DataTableProps) { const theme = useTheme(); const [expanded, setExpanded] = useState<(string | number) | null>(null); @@ -213,10 +216,21 @@ export function DataTable< "& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": { borderBottom: "none", }, + // Cells paint `background.paper`, so a background set on the row + // never shows. Selection and hover both have to be applied at + // cell level, and selection has to come first so hover layers + // on top of it rather than replacing it. + "& .MuiTableBody-root .MuiTableRow-root.is-selected .MuiTableCell-root": { + backgroundColor: theme.palette.action.selected, + }, "& .MuiTableBody-root .MuiTableRow-root.is-clickable:hover .MuiTableCell-root": { backgroundColor: theme.palette.action.rowHover, }, + "& .MuiTableBody-root .MuiTableRow-root.is-selected.is-clickable:hover .MuiTableCell-root": + { + backgroundColor: theme.palette.action.selectedHover, + }, }} > @@ -241,7 +255,12 @@ export function DataTable< return ( { alignItems: "flex-start", color: theme.palette.text.secondary, "&.Mui-selected": { + // Weight stays put, as on ToggleButton: bolding the active tab + // widens it, shifting every tab to its right and dragging the + // indicator to a moving target. Colour and the indicator are + // already carrying the state. color: theme.palette.primary.main, - fontWeight: 600, }, "&:hover": { color: theme.palette.text.secondary, diff --git a/client/src/Components/inputs/TextInput.tsx b/client/src/Components/inputs/TextInput.tsx index 230e7650b..a246aaf04 100644 --- a/client/src/Components/inputs/TextInput.tsx +++ b/client/src/Components/inputs/TextInput.tsx @@ -39,7 +39,7 @@ export const TextInput = forwardRef(function T }, "& input:-webkit-autofill, & input:-webkit-autofill:hover, & input:-webkit-autofill:focus, & input:-webkit-autofill:active": { - WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.default} inset !important`, + WebkitBoxShadow: `0 0 0 100px ${theme.palette.background.paper} inset !important`, WebkitTextFillColor: `${theme.palette.text.primary} !important`, caretColor: theme.palette.text.primary, transition: "background-color 5000s ease-in-out 0s", diff --git a/client/src/Components/sidebar/index.tsx b/client/src/Components/sidebar/index.tsx index 658054dbd..e9a999ebd 100644 --- a/client/src/Components/sidebar/index.tsx +++ b/client/src/Components/sidebar/index.tsx @@ -73,15 +73,14 @@ export const Sidebar = () => { gap={theme.spacing(LAYOUT.SM)} borderRight={`1px solid ${theme.palette.divider}`} width={width} + // On `paper` rather than inheriting the page ground, so the nav + // reads as a surface and the content area stays the darker + // `default`. Opaque, so the blur it used to carry is gone. + bgcolor={theme.palette.background.paper} sx={{ touchAction: "none", transition: transition, zIndex: 1000, - backdropFilter: "blur(8px)", - // Sits on `paper` rather than inheriting the page ground, so - // the nav reads as a surface and the content area behind it - // stays the slightly darker `default`. - backgroundColor: theme.palette.background.paper, }} > { navigate(`/docker/host/${row.id}`); }} - getRowSx={(row) => ({ - backgroundColor: isRowSelected(row.id) - ? theme.palette.action.selected - : "inherit", - })} + isRowSelected={(row) => isRowSelected(row.id)} /> { navigate(`/infrastructure/${row.id}`); }} - getRowSx={(row) => ({ - backgroundColor: isRowSelected(row.id) - ? theme.palette.action.selected - : "inherit", - })} + isRowSelected={(row) => isRowSelected(row.id)} /> { navigate(`/uptime/${row.id}`); }} - getRowSx={(row) => ({ - backgroundColor: isRowSelected(row.id) - ? theme.palette.action.selected - : "inherit", - })} + isRowSelected={(row) => isRowSelected(row.id)} /> Date: Thu, 3 Sep 2026 01:22:12 -0400 Subject: [PATCH 068/137] fix(client): solid red offline banner, and lighter incident icons The offline banner used error.main, which in dark mode is light enough that MUI derives near-black contrastText - a pale salmon bar with dark text on the one surface where urgency is the point. It now uses error.dark with explicit white text: 11.12:1 in light mode, 5.04:1 in dark, and a solid red bar in both. Darkening error.main itself was the other option, but roughly 30 places use it as text or a chart mark on a dark ground, where the lighter value is correct - it would have cost all of them to fix one banner. The incident summary icons rendered at the shared Icon default of 20px in lucide's currentColor, so they sat larger and darker than the 13px label beside them. Now 16px, matching the sidebar and the uptime header buttons, and coloured from SummaryItem so the icon is a supporting mark rather than competing with its own label. The 32px status icon on the active-incidents card keeps its size and semantic colour; it is a focal indicator, not a label decoration. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- .../design-elements/OfflineBanner.tsx | 10 ++++-- .../Incidents/Components/CardSummary.tsx | 31 ++++++++++++++++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/client/src/Components/design-elements/OfflineBanner.tsx b/client/src/Components/design-elements/OfflineBanner.tsx index 062b1ca0d..33b26ae53 100644 --- a/client/src/Components/design-elements/OfflineBanner.tsx +++ b/client/src/Components/design-elements/OfflineBanner.tsx @@ -37,8 +37,14 @@ export const OfflineBanner = ({ visible }: OfflineBannerProps) => { left: 0, right: 0, zIndex: theme.zIndex.snackbar, - backgroundColor: theme.palette.error.main, - color: theme.palette.error.contrastText, + // `error.dark` rather than `main`: in dark mode `main` is light + // enough that MUI derives near-black contrastText, which reads as + // a warning rather than an outage on the one surface where + // urgency matters. `dark` keeps a solid red bar with white text + // in both modes, and leaves `main` alone for the ~30 places that + // use it as text or a chart mark on a dark ground. + backgroundColor: theme.palette.error.dark, + color: "#FFFFFF", px: theme.spacing(8), py: theme.spacing(4), transition: "top 1s ease-in-out", diff --git a/client/src/Pages/Incidents/Components/CardSummary.tsx b/client/src/Pages/Incidents/Components/CardSummary.tsx index 60dadaa74..fa4db83e6 100644 --- a/client/src/Pages/Incidents/Components/CardSummary.tsx +++ b/client/src/Pages/Incidents/Components/CardSummary.tsx @@ -30,6 +30,10 @@ const SummaryItem = ({ icon, label, value }: SummaryItemProps) => { direction="row" alignItems="center" gap={theme.spacing(2)} + // The icons are supporting marks beside the label, not content, + // so they take secondary ink rather than lucide's currentColor + // default, which renders them heavier than the text. + sx={{ "& svg": { color: theme.palette.text.secondary } }} > {icon} {label} @@ -143,7 +147,11 @@ const SummaryIncidentItem = ({ incident }: { incident: IncidentSummaryItem }) => gap: theme.spacing(2), }} > - + { return ( } + icon={ + + } label={t("pages.incidents.summaryCard.incidentStats.totalIncidents")} value={summary?.total || 0} /> } + icon={ + + } label={t("pages.incidents.summaryCard.incidentStats.mostAffectedMonitor")} value={mostAffected} /> } + icon={ + + } label={t("pages.incidents.summaryCard.incidentStats.avgResolutionTime")} value={ summary.total > 0 From ac94ae0fdd738b2d1a34d6d749c0545be616e32f Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 3 Sep 2026 01:24:44 -0400 Subject: [PATCH 069/137] fix(client): make 16 the Icon default and size the stragglers Third time this bug has surfaced, so this fixes the cause. Icon defaulted to 20px, but 17 call sites already asked for 16 explicitly against 2 for 20, and the sidebar clamps to 16 in CSS - so 16 was the house size and the 20 default was one nobody chose. Anything bare rendered above the cap height of the 13px text beside it. The default is now 16. Five sites that were relying on the old default and sit next to labels are pinned explicitly anyway (team header, status page controls, status page delete, theme toggle), and the public status page's warning icon is pinned at 20 so the default change does not alter a page users publish. Sidebar icons are unaffected either way - their CSS already forced 16. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Components/design-elements/Icon.tsx | 5 ++++- client/src/Components/sidebar/Authfooter.tsx | 5 ++++- .../Account/components/HeaderTeamControls.tsx | 14 ++++++++++++-- .../Components/HeaderConfigStatusControls.tsx | 7 ++++++- .../Status/Components/HeaderStatusPageControls.tsx | 7 ++++++- .../Status/themes/shared/BaseStatusPage.tsx | 1 + 6 files changed, 33 insertions(+), 6 deletions(-) diff --git a/client/src/Components/design-elements/Icon.tsx b/client/src/Components/design-elements/Icon.tsx index 95e68826b..3832cb764 100644 --- a/client/src/Components/design-elements/Icon.tsx +++ b/client/src/Components/design-elements/Icon.tsx @@ -8,7 +8,10 @@ interface IconProps { color?: string; } -const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5, color }: IconProps) => { +// 16 is the house size: 17 call sites ask for it explicitly against 2 for 20, +// and the sidebar clamps to 16 in CSS. A 20px default put icons above the cap +// height of the 13px text they sit beside. +const Icon = ({ icon: Icon, size = 16, strokeWidth = 1.5, color }: IconProps) => { return ( }} sx={menuItemSx} > - + {mode === "light" ? t("pages.settings.form.ui.option.theme.dark") : t("pages.settings.form.ui.option.theme.light")} diff --git a/client/src/Pages/Account/components/HeaderTeamControls.tsx b/client/src/Pages/Account/components/HeaderTeamControls.tsx index 51fea25b6..b3c0f3293 100644 --- a/client/src/Pages/Account/components/HeaderTeamControls.tsx +++ b/client/src/Pages/Account/components/HeaderTeamControls.tsx @@ -59,7 +59,12 @@ export const HeaderTeamControls = ({ @@ -144,12 +144,12 @@ export const MonitorListPage = ({ )} {children} ); diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx index f7664c1b7..642d871d3 100644 --- a/client/src/Pages/Uptime/Monitors/index.tsx +++ b/client/src/Pages/Uptime/Monitors/index.tsx @@ -4,7 +4,7 @@ import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsT import { useMonitorListController } from "@/Hooks/useMonitorListController"; const UptimeMonitorsPage = () => { - const c = useMonitorListController({ + const monitorListController = useMonitorListController({ types: "selectable", checksLimit: 25, refreshInterval: 5000, @@ -17,25 +17,25 @@ const UptimeMonitorsPage = () => { headerKey="uptime" page="uptime" actionLink="/uptime/create" - controller={c} + controller={monitorListController} bulkActions > ); From f7e1793bac3b1eeead5a60cfe29ef7d78c7a97aa Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 3 Sep 2026 12:56:44 -0700 Subject: [PATCH 078/137] update tests --- server/openapi/routes/monitor.ts | 12 ++ .../providers/network/dockerProvider.test.ts | 75 +++++++++++- .../test/unit/services/bufferService.test.ts | 113 ++++++++++++++++-- .../test/unit/services/checkProducer.test.ts | 26 ++++ .../unit/services/dockerLogsService.test.ts | 87 ++++++++++++++ .../test/unit/services/monitorService.test.ts | 65 ++++++++++ .../test/unit/services/workerHelper.test.ts | 10 +- .../unit/validation/monitorValidation.test.ts | 15 +++ 8 files changed, 392 insertions(+), 11 deletions(-) create mode 100644 server/test/unit/services/dockerLogsService.test.ts 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/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts index 4a9e3fe52..df202e3fd 100644 --- a/server/test/unit/providers/network/dockerProvider.test.ts +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -5,6 +5,7 @@ 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 ───────────────────────────────────────────────────────────────── @@ -53,10 +54,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 +75,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 ───────────────────────────────────────────────────────────────── @@ -278,6 +288,65 @@ 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()); + + 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()); + + 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()); + 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()); + + 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()); + + expect(logs).toHaveBeenCalledTimes(2); + expect(logs).toHaveBeenCalledWith({ follow: false, stdout: true, stderr: true, timestamps: true, tail: DOCKER_LOG_TAIL_LINES }); + }); + }); + // ── 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..f4ed45e5a 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,61 @@ 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(); + }); + }); + describe("getPageSpeedDetailsById", () => { it("returns pagespeed details for a pagespeed monitor", async () => { const monitorsRepository = createMonitorsRepositoryMock(); @@ -1802,6 +1866,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..a2ae3f0f5 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -3,8 +3,23 @@ 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(); + }); +}); + const baseDnsBody = { name: "DNS check", type: "dns" as const, From bee3d4221e74e8c6532cb60c237a73c9f6e6442d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 3 Sep 2026 12:57:06 -0700 Subject: [PATCH 079/137] implement display of log head in logs tab --- .../Pages/Docker/ContainerDetails/TabLogs.tsx | 132 +++++++++++++++++- .../Pages/Docker/ContainerDetails/index.tsx | 7 +- client/src/Types/Check.ts | 25 ++++ client/src/Types/Monitor.ts | 5 + client/src/Utils/MonitorUtils.ts | 43 +++++- 5 files changed, 205 insertions(+), 7 deletions(-) diff --git a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx index 9dcea4842..9bd03e9bc 100644 --- a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx +++ b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx @@ -1,3 +1,131 @@ -export const TabLogs = () => { - return "This is the logs tab"; +// Components +import Stack from "@mui/material/Stack"; +import { BaseBox } from "@/Components/design-elements"; +import Typography from "@mui/material/Typography"; + +// Types +import type { DockerContainerLogsResponse } from "@/Types/Monitor"; + +// Hooks +import { useGet } from "@/Hooks/UseApi"; +import { useState, useEffect, useMemo } from "react"; +import type { DockerLog } from "@/Types/Check"; +import { useSelector } from "react-redux"; + +// Util +import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; +import { flattenDockerLogs } from "@/Utils/MonitorUtils"; +import { Box } from "@mui/material"; +import { formatDateWithTz } from "@/Utils/TimeUtils"; +import type { RootState } from "@/store"; + +interface TabLogsProps { + monitorId?: string; + containerName?: string; +} + +export const TabLogs = ({ monitorId, containerName }: TabLogsProps) => { + // State + const [olderLogs, setOlderLogs] = useState([]); + const [olderCursor, setOlderCursor] = useState(undefined); // undefined -> no older requested, string -> cursor, null -> no more data + const [loadingOlder, setLoadingOlder] = useState(false); + const [odlerError, setOlderError] = useState(null); + + const baseLogsUrl = + monitorId && containerName + ? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs` + : null; + + const { + data: head, + isLoading, + isValidating, + error: headError, + refetch, + } = useGet( + baseLogsUrl, + {}, + { + refreshInterval: 10_000, + keepPreviousData: true, + revalidateOnFocus: true, + } + ); + + const rows = useMemo(() => flattenDockerLogs(head?.logs ?? []), [head?.logs]); + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + return ( + + + {`${rows.length} recent lines`} + {isValidating && "Refreshing..."} + + + {[...rows].reverse().map((row) => { + return ( + + + + {formatDateWithTz(row.ts, "MMM D HH:mm:ss", uiTimezone)} + + + + {row.stream} + + + + {row.text} + + + {row.gapBefore && ( + + + + {"Some lines were skipped between checks"} + + + + )} + + ); + })} + + + ); }; diff --git a/client/src/Pages/Docker/ContainerDetails/index.tsx b/client/src/Pages/Docker/ContainerDetails/index.tsx index b4583e4c1..51f765785 100644 --- a/client/src/Pages/Docker/ContainerDetails/index.tsx +++ b/client/src/Pages/Docker/ContainerDetails/index.tsx @@ -70,7 +70,12 @@ 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..300bd2ce4 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"; @@ -322,6 +323,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)) + ); + }); +}; From a5bb186814b96b6f6ed5d19f84c7e41f159a75e3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 11:28:10 -0700 Subject: [PATCH 080/137] wrap basebox in fowardRef --- client/src/Components/design-elements/BaseBox.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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"; From 795e1caca4bb63bbec6f8293695659af811f9a38 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 11:42:11 -0700 Subject: [PATCH 081/137] log display --- .../Components/HeaderLogs.tsx | 16 +++ .../ContainerDetails/Components/RowLog.tsx | 74 +++++++++++ .../Pages/Docker/ContainerDetails/TabLogs.tsx | 119 ++++++++---------- .../Pages/Docker/ContainerDetails/index.tsx | 2 +- 4 files changed, 141 insertions(+), 70 deletions(-) create mode 100644 client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx create mode 100644 client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx 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..b5cdec704 --- /dev/null +++ b/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx @@ -0,0 +1,16 @@ +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +export const HeaderLogs = ({ + lines, + isValidating, +}: { + lines: number; + isValidating: boolean; +}) => { + return ( + + {`${lines} recent lines`} + {isValidating && "Refreshing..."} + + ); +}; 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..82ba9f259 --- /dev/null +++ b/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx @@ -0,0 +1,74 @@ +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"; + +// 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); + + return ( + + + + + {formatDateWithTz(row.ts, "MMM D HH:mm:ss", uiTimezone)} + + + + + + {row.stream} + + : {row.text} + + + + {row.gapBefore ? ( + + Some log entries were skipped + + + ) : ( + + )} + + ); +}; diff --git a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx index 9bd03e9bc..6d4087c53 100644 --- a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx +++ b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx @@ -1,30 +1,41 @@ // Components import Stack from "@mui/material/Stack"; import { BaseBox } from "@/Components/design-elements"; -import Typography from "@mui/material/Typography"; +import { HeaderLogs } from "@/Pages/Docker/ContainerDetails/Components/HeaderLogs"; // Types import type { DockerContainerLogsResponse } from "@/Types/Monitor"; // Hooks import { useGet } from "@/Hooks/UseApi"; -import { useState, useEffect, useMemo } from "react"; +import { + useState, + useEffect, + useLayoutEffect, + useMemo, + Fragment, + useRef, + useCallback, +} from "react"; import type { DockerLog } from "@/Types/Check"; -import { useSelector } from "react-redux"; - +import { useTheme } from "@mui/material"; // Util import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; import { flattenDockerLogs } from "@/Utils/MonitorUtils"; -import { Box } from "@mui/material"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; -import type { RootState } from "@/store"; +import { RowLog } from "@/Pages/Docker/ContainerDetails/Components/RowLog"; interface TabLogsProps { monitorId?: string; containerName?: string; } +const SCROLL_PIN_THRESHOLD_PX = 8; +const isScrolledToBottom = (el: HTMLElement) => + el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_PIN_THRESHOLD_PX; + export const TabLogs = ({ monitorId, containerName }: TabLogsProps) => { + const theme = useTheme(); + // State const [olderLogs, setOlderLogs] = useState([]); const [olderCursor, setOlderCursor] = useState(undefined); // undefined -> no older requested, string -> cursor, null -> no more data @@ -53,76 +64,46 @@ export const TabLogs = ({ monitorId, containerName }: TabLogsProps) => { ); const rows = useMemo(() => flattenDockerLogs(head?.logs ?? []), [head?.logs]); - const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + + const logBoxRef = useRef(null); + const pinnedToBottom = useRef(true); + + // Always pin to bottom when container/monitor changes + useLayoutEffect(() => { + pinnedToBottom.current = true; + }, [monitorId, containerName]); + + // New log rows + useLayoutEffect(() => { + const el = logBoxRef.current; + if (!el || !pinnedToBottom.current) return; + el.scrollTop = el.scrollHeight; + }, [rows]); + + const handleScroll = useCallback((event: React.UIEvent) => { + pinnedToBottom.current = isScrolledToBottom(event.currentTarget); + }, []); + return ( - - {`${rows.length} recent lines`} - {isValidating && "Refreshing..."} - + - {[...rows].reverse().map((row) => { + {rows.map((row) => { return ( - - - - {formatDateWithTz(row.ts, "MMM D HH:mm:ss", uiTimezone)} - - - - {row.stream} - - - - {row.text} - - - {row.gapBefore && ( - - - - {"Some lines were skipped between checks"} - - - - )} - + ); })} diff --git a/client/src/Pages/Docker/ContainerDetails/index.tsx b/client/src/Pages/Docker/ContainerDetails/index.tsx index 51f765785..eaf370318 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<{ From 37e1005479526f5ca9a87b24622b26f2e520654f Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 11:54:29 -0700 Subject: [PATCH 082/137] add comment --- server/src/domain/docker/docker-log.service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/src/domain/docker/docker-log.service.ts b/server/src/domain/docker/docker-log.service.ts index a67888de0..646666bab 100644 --- a/server/src/domain/docker/docker-log.service.ts +++ b/server/src/domain/docker/docker-log.service.ts @@ -76,6 +76,9 @@ export class DockerLogsService implements IDockerLogsService { }, 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, From 6e0afe223e2480a8b0d3030e2466cb4afeb0a528 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 12:06:17 -0700 Subject: [PATCH 083/137] add after to docker logs query --- .../src/api/controllers/monitorController.ts | 3 ++- .../src/api/validation/monitorValidation.ts | 20 ++++++++++++----- .../docker/docker-log.repository.interface.ts | 2 +- .../docker/docker-log.repository.mongo.ts | 8 +++++-- server/src/domain/monitors/monitor.service.ts | 8 +++++-- .../test/unit/services/monitorService.test.ts | 22 +++++++++++++++++++ .../unit/validation/monitorValidation.test.ts | 8 +++++++ 7 files changed, 60 insertions(+), 11 deletions(-) diff --git a/server/src/api/controllers/monitorController.ts b/server/src/api/controllers/monitorController.ts index 1da1d430e..12e8f8520 100644 --- a/server/src/api/controllers/monitorController.ts +++ b/server/src/api/controllers/monitorController.ts @@ -205,7 +205,7 @@ class MonitorController implements IMonitorController { const validatedQuery = getDockerContainerLogsQueryValidation.parse(req.query); const { monitorId, containerName } = validatedParams; - const { before, limit } = validatedQuery; + const { before, after, limit } = validatedQuery; const teamId = requireTeamId(req.user?.teamId); const data = await this.monitorService.getDockerContainerLogs({ @@ -213,6 +213,7 @@ class MonitorController implements IMonitorController { monitorId, containerName, before: before ? new Date(before) : undefined, + after: after ? new Date(after) : undefined, limit, }); diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 32d2abee2..880432325 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -333,11 +333,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(), - limit: z.coerce.number().int().min(1).max(DOCKER_LOG_PAGE_MAX).default(DOCKER_LOG_PAGE_DEFAULT), -}); - +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 diff --git a/server/src/domain/docker/docker-log.repository.interface.ts b/server/src/domain/docker/docker-log.repository.interface.ts index 59745ba4a..10f0ce8aa 100644 --- a/server/src/domain/docker/docker-log.repository.interface.ts +++ b/server/src/domain/docker/docker-log.repository.interface.ts @@ -3,7 +3,7 @@ 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; limit: number }): 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 index f67d87a04..8ee90e535 100644 --- a/server/src/domain/docker/docker-log.repository.mongo.ts +++ b/server/src/domain/docker/docker-log.repository.mongo.ts @@ -64,22 +64,26 @@ class MongoDockerLogsRepository implements IDockerLogsRepository { 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: -1 }) + .sort({ checkedAt: after ? 1 : -1 }) .limit(limit) .lean(); - return docs.map(this.toEntity); + const entities = docs.map(this.toEntity); + return after ? entities.reverse() : entities; }; deleteByMonitorId = async (monitorId: string) => diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index bc4750de6..43cba16d8 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -80,6 +80,7 @@ export interface IMonitorService { monitorId: string; containerName: string; before?: Date; + after?: Date; limit: number; }): Promise; getGeoChecksByMonitorId(args: { @@ -403,12 +404,14 @@ export class MonitorService implements IMonitorService { 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); @@ -426,8 +429,9 @@ export class MonitorService implements IMonitorService { }); } - const logs = await this.dockerLogsRepository.findByContainerName({ monitorId, containerName, before, limit }); - return { logs, nextCursor: logs.length === limit ? (logs[logs.length - 1]?.checkedAt ?? null) : null }; + 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 ({ diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index f4ed45e5a..1bb4b3423 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -656,6 +656,28 @@ describe("MonitorService", () => { 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", () => { diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index a2ae3f0f5..96dbb30ce 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -18,6 +18,14 @@ describe("getDockerContainerLogsQueryValidation", () => { 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 = { From 1b97539eae2c2bf9919fcd2ad040d6b6bd091573 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 12:06:35 -0700 Subject: [PATCH 084/137] export API response interface --- client/src/Hooks/UseApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; From 7dbc7b30646897c01443d3e8ca4798a0dc86d9f1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 13:16:11 -0700 Subject: [PATCH 085/137] wrap showToast in useMemo --- client/src/Hooks/UseToast.ts | 43 +++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 20 deletions(-) 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 }; + }, []); }; From e0129103328a28113fa5900d451a9002dd6351a6 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 13:16:22 -0700 Subject: [PATCH 086/137] add loading older logs --- .../Components/HeaderLogs.tsx | 71 +++++++- .../ContainerDetails/Components/RowLog.tsx | 6 +- .../Pages/Docker/ContainerDetails/TabLogs.tsx | 172 ++++++++++++++---- client/src/locales/en.json | 12 ++ 4 files changed, 219 insertions(+), 42 deletions(-) diff --git a/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx b/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx index b5cdec704..b1ccddc2c 100644 --- a/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx +++ b/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx @@ -1,16 +1,69 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -export const HeaderLogs = ({ - lines, - isValidating, -}: { +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; - isValidating: boolean; -}) => { + 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 ( - - {`${lines} recent lines`} - {isValidating && "Refreshing..."} + + + {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 index 82ba9f259..4a022e44e 100644 --- a/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx +++ b/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx @@ -8,6 +8,7 @@ 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"; @@ -18,6 +19,7 @@ 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 ( @@ -63,7 +65,9 @@ export const RowLog = ({ row }: { row: DockerLogRow }) => { alignItems={"center"} py={SPACING.MD} > - Some log entries were skipped + + {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 6d4087c53..eb5f9ce88 100644 --- a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx +++ b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx @@ -1,28 +1,33 @@ // Components import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; import { BaseBox } 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 { useGet } from "@/Hooks/UseApi"; import { useState, useEffect, useLayoutEffect, useMemo, - Fragment, useRef, useCallback, } from "react"; -import type { DockerLog } from "@/Types/Check"; import { useTheme } from "@mui/material"; -// Util -import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; +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"; -import { RowLog } from "@/Pages/Docker/ContainerDetails/Components/RowLog"; interface TabLogsProps { monitorId?: string; @@ -30,74 +35,177 @@ interface TabLogsProps { } 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 }: TabLogsProps) => { const theme = useTheme(); + const { t } = useTranslation(); + const { toastError } = useToast(); - // State - const [olderLogs, setOlderLogs] = useState([]); - const [olderCursor, setOlderCursor] = useState(undefined); // undefined -> no older requested, string -> cursor, null -> no more data + // 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 [odlerError, setOlderError] = useState(null); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); - const baseLogsUrl = + const logsUrl = monitorId && containerName ? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs` : null; - const { - data: head, - isLoading, - isValidating, - error: headError, - refetch, - } = useGet( - baseLogsUrl, - {}, - { - refreshInterval: 10_000, - keepPreviousData: true, - revalidateOnFocus: true, - } - ); + const newestCursor = logs[0]?.checkedAt; + const oldestCursor = logs[logs.length - 1]?.checkedAt; - const rows = useMemo(() => flattenDockerLogs(head?.logs ?? []), [head?.logs]); + // 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]); - // New log rows + // Rows changed: restore position after a prepend, otherwise honour the pin useLayoutEffect(() => { const el = logBoxRef.current; - if (!el || !pinnedToBottom.current) return; - el.scrollTop = el.scrollHeight; + 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]); + return ( + {rows.length > 0 && ( + + {olderExhausted ? ( + + {t("pages.docker.container.logs.noOlder")} + + ) : ( + + )} + + )} {rows.map((row) => { return ( Date: Fri, 4 Sep 2026 14:06:46 -0700 Subject: [PATCH 087/137] add opt in docker logs flag --- .../src/api/validation/monitorValidation.ts | 5 ++++ server/src/domain/monitors/monitor.model.ts | 4 +++ .../monitors/monitor.repository.mongo.ts | 1 + server/src/domain/monitors/monitor.type.ts | 1 + server/src/service/network/DockerProvider.ts | 9 ++++-- .../providers/network/dockerProvider.test.ts | 28 +++++++++++++++---- .../unit/validation/monitorValidation.test.ts | 13 +++++++++ 7 files changed, 54 insertions(+), 7 deletions(-) diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 880432325..c11df8f80 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -177,6 +177,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(), }) @@ -222,6 +223,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(), }) @@ -298,6 +300,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(), @@ -387,6 +391,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(), diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index 89888900a..f45b613a2 100644 --- a/server/src/domain/monitors/monitor.model.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -306,6 +306,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..d31d830ac 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -454,6 +454,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.type.ts b/server/src/domain/monitors/monitor.type.ts index 49fc3cf57..587112c24 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -109,6 +109,7 @@ export interface Monitor { geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; + dockerLogsEnabled?: boolean; dnsServer?: string; dnsRecordType?: DnsRecordType; recentChecks: CheckSnapshot[]; diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index f2ece3f4e..38bb59fef 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -279,6 +279,11 @@ export class DockerProvider implements IStatusProvider { } }; + 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)); @@ -307,8 +312,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 rawLogs = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerLogs(docker, s)); - const logs = rawLogs.filter((log) => log !== null); + const logs = monitor.dockerLogsEnabled ? await this.collectLogs(docker, summaries) : undefined; + const summary: DockerContainerSummary = { total: containers.length, running: containers.filter((c) => c.state === "running").length, diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts index df202e3fd..ba9d90568 100644 --- a/server/test/unit/providers/network/dockerProvider.test.ts +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -297,7 +297,7 @@ describe("DockerProvider", () => { ); const { provider } = setup({ logs }); - const result = await provider.handle(makeMonitor()); + 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" }, @@ -309,7 +309,7 @@ describe("DockerProvider", () => { 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()); + 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" }, @@ -321,7 +321,7 @@ describe("DockerProvider", () => { 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()); + 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); @@ -330,7 +330,7 @@ describe("DockerProvider", () => { 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()); + 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" })); @@ -340,11 +340,29 @@ describe("DockerProvider", () => { 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()); + 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 ─────────────────────────────────────── diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index 96dbb30ce..bae34f830 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -233,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({ From c1af8995e40aba4fbc2354511a88a31cedef0b0c Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 14:07:10 -0700 Subject: [PATCH 088/137] add opt in docker logs --- client/src/Hooks/useMonitorForm.ts | 1 + client/src/Pages/CreateMonitor/index.tsx | 18 +++++++++++++++++- client/src/Types/Monitor.ts | 1 + client/src/Validation/monitor.ts | 1 + client/src/locales/en.json | 9 +++++++++ 5 files changed, 29 insertions(+), 1 deletion(-) 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..443170ba7 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -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 @@ -823,6 +826,19 @@ const CreateMonitorPage = () => { /> )} + {showStep(1) && watchedType === "docker" && ( + + } + /> + )} + {showStep(2) && (watchedType === "http" || watchedType === "grpc" || diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 300bd2ce4..c81df9c82 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -159,6 +159,7 @@ export interface Monitor { geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; + dockerLogsEnabled?: boolean; dnsServer?: string; dnsRecordType?: DnsRecordType; recentChecks: CheckSnapshot[]; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 00ab3aaff..0cc832405 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -112,6 +112,7 @@ const portSchema = baseSchema.extend({ const dockerSchema = baseSchema.extend({ type: z.literal("docker"), url: z.string().min(1, "Container ID 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 af1a11f4b..ce8ff03dc 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -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.", From 074b94b061785704c5b0ddf9b7ad1188e57296dd Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 14:09:41 -0700 Subject: [PATCH 089/137] fix stale docker strings --- client/src/Pages/CreateMonitor/index.tsx | 4 ++-- client/src/Validation/monitor.ts | 2 +- client/src/locales/en.json | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 443170ba7..afdc9a54c 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, diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 0cc832405..3a1f479dd 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -111,7 +111,7 @@ 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 }), }); diff --git a/client/src/locales/en.json b/client/src/locales/en.json index ce8ff03dc..438a2575b 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 or ssh://user@host" }, "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 host to monitor: a local socket (unix:///var/run/docker.sock or /var/run/docker.sock) or a remote host over SSH (ssh://user@host, with an optional :port). 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.", From 71fed33ecde265cb7495158216c25f4bcc6e5154 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 14:19:11 -0700 Subject: [PATCH 090/137] purge ssh --- client/src/locales/en.json | 4 +-- .../src/api/validation/monitorValidation.ts | 17 ++------- .../0012_migrateDockerMonitorUrls.ts | 6 ++-- server/src/domain/monitors/monitor.model.ts | 3 -- .../monitors/monitor.repository.mongo.ts | 1 - server/src/domain/monitors/monitor.type.ts | 1 - server/src/service/network/DockerProvider.ts | 26 +------------- .../providers/network/dockerProvider.test.ts | 35 +------------------ .../unit/validation/monitorValidation.test.ts | 24 ++----------- 9 files changed, 13 insertions(+), 104 deletions(-) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 438a2575b..b3aa6467c 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -634,7 +634,7 @@ "option": { "dockerHost": { "label": "Docker host", - "placeholder": "unix:///var/run/docker.sock or ssh://user@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 host to monitor: a local socket (unix:///var/run/docker.sock or /var/run/docker.sock) or a remote host over SSH (ssh://user@host, with an optional :port). Every container on the host is monitored.", + "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.", diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index c11df8f80..ddea832ef 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -120,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", }); } }; @@ -164,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(), @@ -201,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, @@ -283,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), @@ -379,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(), 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/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index f45b613a2..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, diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index d31d830ac..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, diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index 587112c24..12fcd1036 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -92,7 +92,6 @@ export interface Monitor { tags: string[]; customUpCodes: HttpStatusCode[]; secret?: string; - sshPrivateKey?: string; cpuAlertThreshold: number; cpuAlertCounter: number; memoryAlertThreshold: number; diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 38bb59fef..776da3f92 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -63,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 { diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts index ba9d90568..4c27985ef 100644 --- a/server/test/unit/providers/network/dockerProvider.test.ts +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -9,8 +9,6 @@ import { DOCKER_LOG_TAIL_LINES } from "../../../../src/domain/docker/docker.type // ── Fixtures ───────────────────────────────────────────────────────────────── -const PRIVATE_KEY = "-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----"; - const makeMonitor = (overrides?: Partial): Monitor => ({ id: "mon-1", @@ -116,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 ─────────────────────────────────────── @@ -151,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) => { @@ -161,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(); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index bae34f830..1cd224ab5 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -608,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", () => { @@ -621,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"); }); }); @@ -660,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(); }); }); From 9a541dac21b661c08e9d585d7133c73268b833cf Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 14:25:27 -0700 Subject: [PATCH 091/137] mock missing dependency --- server/test/helpers/heartbeatTestHarness.ts | 2 ++ 1 file changed, 2 insertions(+) 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()); From e2362ca80905419917531d7e21644922759e540d Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 14:34:24 -0700 Subject: [PATCH 092/137] add empty view if logs disabled --- .../Pages/Docker/ContainerDetails/TabLogs.tsx | 18 +++++++++++++++--- .../Pages/Docker/ContainerDetails/index.tsx | 1 + client/src/locales/en.json | 7 ++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx index eb5f9ce88..8839d553b 100644 --- a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx +++ b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx @@ -1,7 +1,7 @@ // Components import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -import { BaseBox } from "@/Components/design-elements"; +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"; @@ -32,6 +32,7 @@ import { flattenDockerLogs } from "@/Utils/MonitorUtils"; interface TabLogsProps { monitorId?: string; containerName?: string; + enabled: boolean; } const SCROLL_PIN_THRESHOLD_PX = 8; @@ -45,7 +46,7 @@ const fetchLogs = async (url: string, params: { before?: string; after?: string return res.data.data; }; -export const TabLogs = ({ monitorId, containerName }: TabLogsProps) => { +export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) => { const theme = useTheme(); const { t } = useTranslation(); const { toastError } = useToast(); @@ -58,7 +59,7 @@ export const TabLogs = ({ monitorId, containerName }: TabLogsProps) => { const [lastUpdatedAt, setLastUpdatedAt] = useState(null); const logsUrl = - monitorId && containerName + enabled && monitorId && containerName ? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs` : null; @@ -167,6 +168,17 @@ export const TabLogs = ({ monitorId, containerName }: TabLogsProps) => { } }, [logsUrl, oldestCursor, loadingOlder, t, toastError]); + if (!enabled) { + return ( + + ); + } + return ( { )} diff --git a/client/src/locales/en.json b/client/src/locales/en.json index b3aa6467c..06a0ff8e5 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -976,7 +976,12 @@ "loadingOlder": "Loading...", "noOlder": "Beginning of retained logs", "loadError": "Could not load logs", - "loadOlderError": "Could not load older logs" + "loadOlderError": "Could not load older logs", + "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": { From bd7b6038f56fd95a906358ec46311c6722892bc1 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 14:36:26 -0700 Subject: [PATCH 093/137] add empty view for no logs --- client/src/Pages/Docker/ContainerDetails/TabLogs.tsx | 9 +++++++++ client/src/locales/en.json | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx index 8839d553b..e7e29207d 100644 --- a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx +++ b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx @@ -179,6 +179,15 @@ export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) => ); } + if (lastUpdatedAt !== null && rows.length === 0) { + return ( + + ); + } + return ( Date: Fri, 4 Sep 2026 14:41:10 -0700 Subject: [PATCH 094/137] add docker redirect --- client/src/Pages/CreateMonitor/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index afdc9a54c..3658276cd 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -376,6 +376,8 @@ const CreateMonitorPage = () => { navigate("/pagespeed"); } else if (pageType === "hardware") { navigate("/infrastructure"); + } else if (pageType === "docker") { + navigate("/docker"); } else { navigate("/uptime"); } From ce9dce48b322da0871a9d2c3cd4ac2830ec35a39 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 4 Sep 2026 14:55:07 -0700 Subject: [PATCH 095/137] update docker permissions --- docs/hosting/checkmate-so/docker-compose.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/hosting/checkmate-so/docker-compose.yaml b/docs/hosting/checkmate-so/docker-compose.yaml index 96849fecb..f588db87a 100644 --- a/docs/hosting/checkmate-so/docker-compose.yaml +++ b/docs/hosting/checkmate-so/docker-compose.yaml @@ -24,6 +24,8 @@ services: checkmate-demo: image: ghcr.io/bluewave-labs/checkmate:master container_name: checkmate-demo + group_add: + - "989" restart: always env_file: - server-prod.env @@ -68,6 +70,8 @@ services: checkmate-staging: image: ghcr.io/bluewave-labs/checkmate:develop container_name: checkmate-staging + group_add: + - "989" restart: always env_file: - server-staging.env From 1492569b739888171d25ad33e9402dcaac371ae4 Mon Sep 17 00:00:00 2001 From: Gorkem Cetin <167266851+gorkem-bwl@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:10:05 +0300 Subject: [PATCH 096/137] Remove code owners from CODEOWNERS file --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 10bf888d9..8b1378917 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @ajhollid @mohicody @Br0wnHammer @Owaiseimdad @karenvicent @shanikauwu1 \ No newline at end of file + From 02b0daa37da4b7794afbd398d5b9a292fe1b5cc5 Mon Sep 17 00:00:00 2001 From: Gorkem Cetin <167266851+gorkem-bwl@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:11:51 +0300 Subject: [PATCH 097/137] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bb5185378..93fe5807c 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ Feel free to ask questions or share your ideas - we'd love to hear from you! ## Contributing -We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers. +We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Malena](https://github.com/malenacaroline) and [Mert](https://github.com/mertssmnoglu) helping individuals and businesses monitor their infra and servers. We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned almost 11K+ stars and attracted 150+ contributors from around the globe. From 7862b3c6f9fc1f2283f8e78ce4f1e9205757e35e Mon Sep 17 00:00:00 2001 From: Gorkem Cetin <167266851+gorkem-bwl@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:14:17 +0300 Subject: [PATCH 098/137] Update image in README.md Replaced the old image with a new image in the README. --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 93fe5807c..de6068fb9 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,7 @@ [![Run on PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=checkmate) - -image +image


    From 2eeb088b618a1c83f08d8abf7f1451789d71e001 Mon Sep 17 00:00:00 2001 From: Ali Shan Ahmad Date: Sun, 6 Sep 2026 08:35:43 +0000 Subject: [PATCH 099/137] [FIX]: rbac by adding the missing isAllowed(["admin", "superadmin"]) authorization middleware to mutating API endpoints for maintenance windows, notifications, and monitor check history. --- server/src/api/routes/checkRoutes.ts | 2 +- .../src/api/routes/maintenanceWindowRoutes.ts | 7 +- server/src/api/routes/notificationRoutes.ts | 11 +-- server/test/unit/middleware/isAllowed.test.ts | 79 +++++++++++++++++++ 4 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 server/test/unit/middleware/isAllowed.test.ts diff --git a/server/src/api/routes/checkRoutes.ts b/server/src/api/routes/checkRoutes.ts index 97974f9df..99c6f8a07 100644 --- a/server/src/api/routes/checkRoutes.ts +++ b/server/src/api/routes/checkRoutes.ts @@ -9,6 +9,6 @@ export const createCheckRoutes = (checkController: ICheckController): Router => router.get("/team", checkController.getChecksByTeam); router.delete("/team", isAllowed(["admin", "superadmin"]), checkController.deleteChecksByTeamId); router.get("/:monitorId", checkController.getChecksByMonitor); - router.delete("/:monitorId", checkController.deleteChecks); + router.delete("/:monitorId", isAllowed(["admin", "superadmin"]), checkController.deleteChecks); return router; }; diff --git a/server/src/api/routes/maintenanceWindowRoutes.ts b/server/src/api/routes/maintenanceWindowRoutes.ts index 22c3fd94a..6796d1a72 100644 --- a/server/src/api/routes/maintenanceWindowRoutes.ts +++ b/server/src/api/routes/maintenanceWindowRoutes.ts @@ -1,15 +1,16 @@ import { IMaintenanceWindowController } from "@/api/controllers/maintenanceWindowController.js"; +import { isAllowed } from "@/api/middleware/isAllowed.js"; import { Router } from "express"; export const createMaintenanceWindowRoutes = (maintenanceWindowController: IMaintenanceWindowController): Router => { const router = Router(); - router.post("/", maintenanceWindowController.createMaintenanceWindows); + router.post("/", isAllowed(["admin", "superadmin"]), maintenanceWindowController.createMaintenanceWindows); router.get("/team/", maintenanceWindowController.getMaintenanceWindowsByTeamId); router.get("/monitor/:monitorId", maintenanceWindowController.getMaintenanceWindowsByMonitorId); router.get("/:id", maintenanceWindowController.getMaintenanceWindowById); - router.patch("/:id", maintenanceWindowController.editMaintenanceWindow); - router.delete("/:id", maintenanceWindowController.deleteMaintenanceWindow); + router.patch("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.editMaintenanceWindow); + router.delete("/:id", isAllowed(["admin", "superadmin"]), maintenanceWindowController.deleteMaintenanceWindow); return router; }; diff --git a/server/src/api/routes/notificationRoutes.ts b/server/src/api/routes/notificationRoutes.ts index 32ea1923c..e2d7bfc86 100644 --- a/server/src/api/routes/notificationRoutes.ts +++ b/server/src/api/routes/notificationRoutes.ts @@ -1,14 +1,15 @@ import { INotificationController } from "@/api/controllers/notificationController.js"; +import { isAllowed } from "@/api/middleware/isAllowed.js"; import { Router } from "express"; export const createNotificationRoutes = (notificationController: INotificationController): Router => { const router = Router(); - router.post("/", notificationController.createNotification); - router.post("/test/all", notificationController.testAllNotifications); - router.post("/test", notificationController.testNotification); + router.post("/", isAllowed(["admin", "superadmin"]), notificationController.createNotification); + router.post("/test/all", isAllowed(["admin", "superadmin"]), notificationController.testAllNotifications); + router.post("/test", isAllowed(["admin", "superadmin"]), notificationController.testNotification); router.get("/team", notificationController.getNotificationsByTeamId); router.get("/:id", notificationController.getNotificationById); - router.delete("/:id", notificationController.deleteNotification); - router.patch("/:id", notificationController.editNotification); + router.delete("/:id", isAllowed(["admin", "superadmin"]), notificationController.deleteNotification); + router.patch("/:id", isAllowed(["admin", "superadmin"]), notificationController.editNotification); return router; }; diff --git a/server/test/unit/middleware/isAllowed.test.ts b/server/test/unit/middleware/isAllowed.test.ts new file mode 100644 index 000000000..6354889b1 --- /dev/null +++ b/server/test/unit/middleware/isAllowed.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import type { NextFunction, Request, Response } from "express"; +import { isAllowed } from "../../../src/api/middleware/isAllowed.ts"; +import { AppError } from "../../../src/utils/AppError.ts"; + +describe("isAllowed middleware", () => { + it("calls next() without error when user has an allowed role", () => { + const middleware = isAllowed(["admin", "superadmin"]); + const req = { + user: { + userId: "user-1", + teamId: "team-1", + email: "admin@example.com", + role: ["admin"], + }, + } as unknown as Request; + const res = {} as Response; + const next = jest.fn() as unknown as NextFunction; + + middleware(req, res, next); + + expect(next).toHaveBeenCalledWith(); + }); + + it("calls next(error) with 403 when user does not have an allowed role", () => { + const middleware = isAllowed(["admin", "superadmin"]); + const req = { + user: { + userId: "user-2", + teamId: "team-1", + email: "user@example.com", + role: ["user"], + }, + } as unknown as Request; + const res = {} as Response; + const next = jest.fn() as unknown as NextFunction; + + middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + const error = (next as unknown as jest.Mock).mock.calls[0][0] as AppError; + expect(error).toBeInstanceOf(AppError); + expect(error.status).toBe(403); + expect(error.message).toBe("Unauthorized"); + }); + + it("calls next(error) with 403 when req.user is undefined", () => { + const middleware = isAllowed(["admin", "superadmin"]); + const req = {} as Request; + const res = {} as Response; + const next = jest.fn() as unknown as NextFunction; + + middleware(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + const error = (next as unknown as jest.Mock).mock.calls[0][0] as AppError; + expect(error).toBeInstanceOf(AppError); + expect(error.status).toBe(403); + expect(error.message).toBe("Unauthorized"); + }); + + it("calls next() when user has multiple roles and at least one matches", () => { + const middleware = isAllowed(["admin", "superadmin"]); + const req = { + user: { + userId: "user-3", + teamId: "team-1", + email: "multi@example.com", + role: ["user", "admin"], + }, + } as unknown as Request; + const res = {} as Response; + const next = jest.fn() as unknown as NextFunction; + + middleware(req, res, next); + + expect(next).toHaveBeenCalledWith(); + }); +}); From 4bbc84ac48dd0fc1f9ccc75210ff10912e7e43f6 Mon Sep 17 00:00:00 2001 From: Malena Aguiar Date: Sun, 6 Sep 2026 08:41:41 -0600 Subject: [PATCH 100/137] feat(sidebar): reorder Docker and Pagespeed menu items Swap Docker and Pagespeed positions in the monitoring section of the sidebar so Docker appears before Infrastructure and Pagespeed comes last. Co-Authored-By: Claude Sonnet 4.6 --- client/src/Components/sidebar/Menu.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/src/Components/sidebar/Menu.tsx b/client/src/Components/sidebar/Menu.tsx index f8446f561..be42fa6e5 100644 --- a/client/src/Components/sidebar/Menu.tsx +++ b/client/src/Components/sidebar/Menu.tsx @@ -32,9 +32,9 @@ export const getMenu = (t: Function) => { icon: , }, { - name: t("components.sidebar.menu.pagespeed"), - path: "pagespeed", - icon: , + name: t("components.sidebar.menu.docker"), + path: "docker", + icon: , }, { name: t("components.sidebar.menu.infrastructure"), @@ -42,9 +42,9 @@ export const getMenu = (t: Function) => { icon: , }, { - name: t("components.sidebar.menu.docker"), - path: "docker", - icon: , + name: t("components.sidebar.menu.pagespeed"), + path: "pagespeed", + icon: , }, ], }, From 67806c94b15290d4add6922f65db6a80624b8bb9 Mon Sep 17 00:00:00 2001 From: egeoztass Date: Mon, 7 Sep 2026 12:31:07 +0200 Subject: [PATCH 101/137] fix: return the created monitor from createMonitor The controller writes the service's return value into the response, but createMonitor was typed Promise and returned nothing, so every successful POST /monitors answered data: null and callers had no way to read the new monitor's id. Fixes #3917 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01D7eViZ2Np5ZpWUYNoRhmAt --- server/src/domain/monitors/monitor.service.ts | 5 +++-- server/test/unit/services/monitorService.test.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index 43cba16d8..9e4f7f6e5 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -60,7 +60,7 @@ const computeRestartsInRange = (aggregate: DockerContainerStatsBucket[]): number export interface IMonitorService { // create - createMonitor(teamId: string, userId: string, body: Partial): Promise; + createMonitor(teamId: string, userId: string, body: Partial): Promise; createMonitors(monitors: Array): Promise; addDemoMonitors(args: { userId: string; teamId: string }): Promise; @@ -180,7 +180,7 @@ export class MonitorService implements IMonitorService { this.incidentsRepository = incidentsRepository; } - createMonitor = async (teamId: string, userId: string, body: Monitor): Promise => { + createMonitor = async (teamId: string, userId: string, body: Monitor): Promise => { // proxyId is only needed in custom mode if (body.proxyMode !== "custom") { delete body.proxyId; @@ -191,6 +191,7 @@ export class MonitorService implements IMonitorService { } this.scheduler.addJob(monitor.id, monitor); + return monitor; }; createMonitors = async (monitors: Array): Promise => { diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index 1bb4b3423..d3f5dd1a7 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -122,16 +122,17 @@ const createService = ( describe("MonitorService", () => { describe("createMonitor", () => { - it("creates a monitor and adds a job", async () => { + it("creates a monitor, adds a job and returns the created document", async () => { const monitorsRepository = createMonitorsRepositoryMock(); const monitor = makeMonitor(); (monitorsRepository.create as jest.Mock).mockResolvedValue(monitor); const { service, jobQueue } = createService({ monitorsRepository }); - await service.createMonitor(TEAM_ID, USER_ID, monitor as any); + const result = await service.createMonitor(TEAM_ID, USER_ID, monitor as any); expect(monitorsRepository.create).toHaveBeenCalledWith(monitor, TEAM_ID, USER_ID); expect(jobQueue.addJob).toHaveBeenCalledWith(MONITOR_ID, monitor); + expect(result).toBe(monitor); }); it("strips a stray proxyId when proxyMode is not custom", async () => { From 7fb8bab4beeb42dcdf12decc8c3133eb64b550f7 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 8 Sep 2026 10:21:44 -0700 Subject: [PATCH 102/137] purge narrative comment --- client/src/Components/design-elements/Table.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index 8650f2bc6..bba04f350 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -80,10 +80,6 @@ export function DataTable< const isSmall = useMediaQuery(theme.breakpoints.down("md")); - // Selecting text inside a row ends in a mouseup on that row, which would - // otherwise navigate away and take the text with it. Ignore the click when - // the selection is inside the row that was clicked - a leftover selection - // elsewhere on the page must not swallow a legitimate click. const hasTextSelectionWithin = (element: Element) => { const selection = window.getSelection(); if (!selection || selection.isCollapsed || !selection.toString().trim()) { From 8609e4bca0b2b2527be2c1365d589147d3ce33e0 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 8 Sep 2026 10:46:36 -0700 Subject: [PATCH 103/137] add admin guard to test all button, fix tests --- .../monitors/HeaderMonitorControls.tsx | 30 ++++++++++--------- server/test/unit/middleware/isAllowed.test.ts | 6 ++-- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/client/src/Components/monitors/HeaderMonitorControls.tsx b/client/src/Components/monitors/HeaderMonitorControls.tsx index 7f6968f0b..c907b96bc 100644 --- a/client/src/Components/monitors/HeaderMonitorControls.tsx +++ b/client/src/Components/monitors/HeaderMonitorControls.tsx @@ -60,20 +60,22 @@ export const HeaderMonitorControls = ({ direction={{ xs: "column", md: "row" }} gap={theme.spacing(2)} > - + {isAdmin && ( + + )}