diff --git a/client/src/Components/design-elements/Breadcrumb.tsx b/client/src/Components/design-elements/Breadcrumb.tsx index ae3e768c2..7ce2560f6 100644 --- a/client/src/Components/design-elements/Breadcrumb.tsx +++ b/client/src/Components/design-elements/Breadcrumb.tsx @@ -83,12 +83,17 @@ export const Breadcrumb = ({ const basePage = segments[0] || t("common.breadcrumbs.home"); const secondSegment = segments[1]; + const thirdSegment = segments[2]; const isActionPage = secondSegment && actionSegments.includes(secondSegment); const isDetailsPage = secondSegment && isId(secondSegment); - const hasSubPage = isActionPage || isDetailsPage; + // page/section/:id routes such as docker/host/:monitorId + const isSectionDetailsPage = Boolean( + secondSegment && !isId(secondSegment) && thirdSegment && isId(thirdSegment) + ); + const hasSubPage = isActionPage || isDetailsPage || isSectionDetailsPage; const getSubPageLabel = (): string => { - if (isActionPage) { + if (isActionPage || isSectionDetailsPage) { return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1); } return t("common.breadcrumbs.details"); diff --git a/client/src/Components/design-elements/StatusLabel.tsx b/client/src/Components/design-elements/StatusLabel.tsx index c3fd089d7..63c5b0f35 100644 --- a/client/src/Components/design-elements/StatusLabel.tsx +++ b/client/src/Components/design-elements/StatusLabel.tsx @@ -1,12 +1,19 @@ import Box from "@mui/material/Box"; import { BaseBox } from "@/Components/design-elements"; import Typography from "@mui/material/Typography"; +import Stack from "@mui/material/Stack"; import type { MonitorStatus } from "@/Types/Monitor"; import type { SxProps } from "@mui/material/styles"; -import { getStatusPalette, getValuePalette } from "@/Utils/MonitorUtils"; +import { + getDockerStatePalette, + getStatusPalette, + getValuePalette, +} from "@/Utils/MonitorUtils"; import { useTheme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; +import type { DockerContainerState } from "@/Types/Check"; +import { SPACING } from "@/Utils/Theme/constants"; export const ValueTypes = ["positive", "negative", "neutral"] as const; export type ValueType = (typeof ValueTypes)[number]; @@ -110,3 +117,36 @@ export const ColoredLabel = ({ text, color }: { text: string; color: string }) = ); }; + +export const DockerStateLabel = ({ + state, + name, + image, +}: { + state: DockerContainerState; + name: string; + image: string; +}) => { + const palette = getDockerStatePalette(state); + const theme = useTheme(); + + return ( + + + + {name} + {image} + + + ); +}; 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 e7c4b9581..f8446f561 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 [ @@ -40,6 +41,11 @@ export const getMenu = (t: Function) => { path: "infrastructure", icon: , }, + { + name: t("components.sidebar.menu.docker"), + path: "docker", + icon: , + }, ], }, { 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..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/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx b/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx new file mode 100644 index 000000000..bf5bdc216 --- /dev/null +++ b/client/src/Pages/Docker/HostDetails/Components/DockerContainersTable.tsx @@ -0,0 +1,120 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { DockerStateLabel, Table } from "@/Components/design-elements"; +// Hooks +import { useTranslation } from "react-i18next"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { useTheme } from "@mui/material/styles"; +import { useNavigate } from "react-router-dom"; + +// Types +import type { Header } from "@/Components/design-elements/Table"; +import type { DockerContainerInfo } from "@/Types/Check"; + +// Utils +import { formatPercentage } from "@/Utils/FormatUtils"; + +interface DockerContainersTableProps { + containers: DockerContainerInfo[]; + onRowClick?: (container: DockerContainerInfo) => void; +} + +export const DockerContainersTable = ({ containers }: DockerContainersTableProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const navigate = useNavigate(); + + const getHeaders = () => { + // const renderSortIcon = (isActive: boolean) => ( + // + // {isActive ? ( + // sortOrder === "asc" ? ( + // + // ) : ( + // + // ) + // ) : null} + // + // ); + const headers: Header[] = [ + { + id: "container", + content: t("pages.docker.host.table.headers.container"), + render: (row) => { + return ( + + ); + }, + }, + { + id: "status", + content: t("common.table.headers.status"), + render: (row) => { + return {row.state}; + }, + }, + { + id: "health", + content: t("pages.docker.host.table.headers.health"), + render: (row) => { + return {row.health}; + }, + }, + { + id: "cpu", + content: t("pages.docker.host.table.headers.cpu"), + render: (row) => { + return {formatPercentage(row.cpuPct ?? 0)}; + }, + }, + { + id: "memory", + content: t("pages.docker.host.table.headers.memory"), + render: (row) => { + return {formatPercentage(row.memoryPct ?? 0)}; + }, + }, + { + id: "restarts", + content: t("pages.docker.host.table.headers.restarts"), + render: (row) => { + return {row.restartCount}; + }, + }, + ]; + return headers; + }; + + let headers = getHeaders(); + + if (isSmall) { + headers = headers.filter((h) => h.id !== "histogram"); + } + + console.log(JSON.stringify(containers, null, 2)); + return ( + + { + 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..1f65836b0 --- /dev/null +++ b/client/src/Pages/Docker/HostDetails/Components/StatBoxes.tsx @@ -0,0 +1,47 @@ +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 + +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 new file mode 100644 index 000000000..7b35e6bfe --- /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/host/${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..e7735fcd0 100644 --- a/client/src/Routes/index.tsx +++ b/client/src/Routes/index.tsx @@ -20,6 +20,11 @@ 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 DockerHostDetails from "@/Pages/Docker/HostDetails"; +import DockerDetails from "@/Pages/Docker/Details"; + // Checks import Checks from "@/Pages/Checks"; @@ -139,6 +144,26 @@ const Routes = () => { path="infrastructure/:monitorId" element={} /> + } + /> + } + /> + } + /> + } + /> + } + /> } diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index 78e970f04..6e5ca29f8 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -119,6 +119,46 @@ export interface CheckTimings { }; } +// Mirrors DockerContainerSummary in server/src/types/network.ts. +export interface DockerContainerSummary { + total: number; + running: number; + stopped: number; + 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; @@ -133,6 +173,7 @@ export interface Check { host?: CheckHostInfo; errors?: CheckErrorInfo[]; capture?: CheckCaptureInfo; + containerSummary?: DockerContainerSummary; net?: CheckNetworkInterfaceInfo[]; accessibility?: number; bestPractices?: number; @@ -287,6 +328,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..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"; @@ -30,7 +32,6 @@ export type MonitorType = (typeof MonitorTypes)[number]; export const SelectableMonitorTypes = [ "http", "ping", - "docker", "port", "game", "grpc", @@ -269,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 401771f94..ef48c2223 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -1,6 +1,7 @@ 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"; export const getMonitorPath = (type: MonitorType): string => { const pathMap: Record = { @@ -12,7 +13,7 @@ export const getMonitorPath = (type: MonitorType): string => { websocket: "uptime", dns: "uptime", unknown: "uptime", - docker: "uptime", + docker: "docker", hardware: "infrastructure", pagespeed: "pagespeed", }; @@ -41,6 +42,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/client/src/locales/en.json b/client/src/locales/en.json index c6a2da621..2e6cdb2bc 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", @@ -924,6 +925,41 @@ "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" + }, + "host": { + "statBoxes": { + "containers": "Containers", + "running": "Running", + "stopped": "Stopped", + "unhealthy": "Unhealthy" + }, + "table": { + "headers": { + "container": "Containers", + "health": "Health", + "cpu": "CPU", + "memory": "Memory", + "restarts": "Restarts" + } + } + } + }, "infrastructure": { "charts": { "labels": { 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 () => {