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": {