Add Docker monitoring integration with Capture API (partial implementation)

Backend changes:
- Update Check model to store Docker container data (container_id, status, health, ports, etc.)
- Refactor networkService to support both local Docker and remote Capture API
  - requestLocalDocker: Existing local Docker socket monitoring
  - requestCaptureDocker: New Capture API endpoint integration
  - requestDocker: Routes based on URL pattern (http/https vs container name)
- Update statusService to handle Docker check data storage
- Enforce proper error handling for Capture API (401, 404, 500, 207 responses)

Frontend changes:
- Add Hardware/Docker tabs to Infrastructure page
- Add query parameter support for monitor type selection
- Prepare form for Docker monitor creation (partial)

Status: Backend complete, frontend in progress
Next: Complete Docker monitor form, add container table component, test end-to-end
This commit is contained in:
Claude committed 2025-11-15 22:27:31 +00:00
1 parent 2d58facb97
commit 0f3c81c052
5 files changed
+278 -55

No files matched your search

@@ -13,7 +13,7 @@ import CustomAlertsSection from "./Components/CustomAlertsSection.jsx";
// Utils
import NotificationsConfig from "@/Components/v1/NotificationConfig/index.jsx";
import { useGetNotificationsByTeamId } from "../../../../Hooks/v1/useNotifications.js";
import { useParams } from "react-router-dom";
import { useParams, useSearchParams } from "react-router-dom";
import { useState, useEffect } from "react";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
@@ -29,11 +29,15 @@ import useInfrastructureSubmit from "./hooks/useInfrastructureSubmit.jsx";
const CreateInfrastructureMonitor = () => {
const { monitorId } = useParams();
const [searchParams] = useSearchParams();
const isCreate = typeof monitorId === "undefined";
const theme = useTheme();
const { t } = useTranslation();
// Get monitor type from query params (default to hardware for backwards compatibility)
const monitorType = searchParams.get("type") || "hardware";
// State
const [https, setHttps] = useState(false);
const [updateTrigger, setUpdateTrigger] = useState(false);
@@ -1,5 +1,6 @@
// Components
import { Stack } from "@mui/material";
import { Stack, Tab } from "@mui/material";
import { TabContext, TabPanel } from "@mui/lab";
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import MonitorCountHeader from "@/Components/v1/MonitorCountHeader/index.jsx";
import MonitorCreateHeader from "@/Components/v1/MonitorCreateHeader/index.jsx";
@@ -8,6 +9,7 @@ import Pagination from "@/Components/v1/Table/TablePagination/index.jsx";
import PageStateWrapper from "@/Components/v1/PageStateWrapper/index.jsx";
import Filter from "./Components/Filters/index.jsx";
import SearchComponent from "../../Uptime/Monitors/Components/SearchComponent/index.jsx";
import CustomTabList from "@/Components/v1/Tab/index.jsx";
// Utils
import { useTheme } from "@emotion/react";
import { useEffect, useState } from "react";
@@ -17,7 +19,6 @@ import { useFetchMonitorsByTeamId } from "../../../../Hooks/v1/monitorHooks.js";
import { useDispatch, useSelector } from "react-redux";
import { setRowsPerPage } from "../../../../Features/UI/uiSlice.js";
// Constants
const TYPES = ["hardware"];
const BREADCRUMBS = [{ name: `infrastructure`, path: "/infrastructure" }];
const InfrastructureMonitors = () => {
@@ -26,6 +27,7 @@ const InfrastructureMonitors = () => {
const dispatch = useDispatch();
// Local state
const [tab, setTab] = useState("hardware");
const [page, setPage] = useState(0);
const [updateTrigger, setUpdateTrigger] = useState(false);
const [selectedStatus, setSelectedStatus] = useState(undefined);
@@ -39,6 +41,14 @@ const InfrastructureMonitors = () => {
const { t } = useTranslation();
// Handlers
const handleTabChange = (event, newTab) => {
setTab(newTab);
setPage(0); // Reset to first page when changing tabs
setSearch(undefined); // Clear search
setSelectedStatus(undefined); // Clear filters
setToFilterStatus(undefined);
};
const handleActionMenuDelete = () => {
setUpdateTrigger(!updateTrigger);
};
@@ -70,9 +80,12 @@ const InfrastructureMonitors = () => {
const field = toFilterStatus !== undefined ? "status" : undefined;
// Determine monitor types based on active tab
const types = tab === "hardware" ? ["hardware"] : ["docker"];
const [monitors, summary, isLoading, networkError] = useFetchMonitorsByTeamId({
limit: 1,
types: TYPES,
types: types,
page,
field: field,
filter: toFilterStatus ?? search,
@@ -82,55 +95,113 @@ const InfrastructureMonitors = () => {
return (
<>
<PageStateWrapper
networkError={networkError}
isLoading={isLoading}
items={monitors}
type="infrastructureMonitor"
fallbackLink="/infrastructure/create"
>
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<MonitorCreateHeader
isAdmin={isAdmin}
isLoading={isLoading}
path="/infrastructure/create"
/>
<Stack direction={"row"}>
<MonitorCountHeader
isLoading={isLoading}
monitorCount={summary?.totalMonitors ?? 0}
/>
<Filter
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
setToFilterStatus={setToFilterStatus}
handleReset={handleReset}
/>
<SearchComponent
monitors={monitors}
onSearchChange={setSearch}
setIsSearching={setIsSearching}
/>
</Stack>
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<MonitorCreateHeader
isAdmin={isAdmin}
isLoading={isLoading}
path={tab === "docker" ? "/infrastructure/create?type=docker" : "/infrastructure/create?type=hardware"}
/>
<MonitorsTable
isLoading={isLoading}
monitors={monitors}
isAdmin={isAdmin}
handleActionMenuDelete={handleActionMenuDelete}
isSearching={isSearching}
/>
<Pagination
itemCount={summary?.totalMonitors}
paginationLabel={t("monitors")}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
/>
</Stack>
</PageStateWrapper>
<TabContext value={tab}>
<CustomTabList value={tab} onChange={handleTabChange}>
<Tab label="Hardware" value="hardware" />
<Tab label="Docker Monitoring" value="docker" />
</CustomTabList>
<TabPanel value="hardware" sx={{ padding: 0 }}>
<PageStateWrapper
networkError={networkError}
isLoading={isLoading}
items={monitors}
type="infrastructureMonitor"
fallbackLink="/infrastructure/create"
>
<Stack gap={theme.spacing(10)}>
<Stack direction={"row"}>
<MonitorCountHeader
isLoading={isLoading}
monitorCount={summary?.totalMonitors ?? 0}
/>
<Filter
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
setToFilterStatus={setToFilterStatus}
handleReset={handleReset}
/>
<SearchComponent
monitors={monitors}
onSearchChange={setSearch}
setIsSearching={setIsSearching}
/>
</Stack>
<MonitorsTable
isLoading={isLoading}
monitors={monitors}
isAdmin={isAdmin}
handleActionMenuDelete={handleActionMenuDelete}
isSearching={isSearching}
/>
<Pagination
itemCount={summary?.totalMonitors}
paginationLabel={t("monitors")}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
/>
</Stack>
</PageStateWrapper>
</TabPanel>
<TabPanel value="docker" sx={{ padding: 0 }}>
<PageStateWrapper
networkError={networkError}
isLoading={isLoading}
items={monitors}
type="infrastructureMonitor"
fallbackLink="/infrastructure/create"
>
<Stack gap={theme.spacing(10)}>
<Stack direction={"row"}>
<MonitorCountHeader
isLoading={isLoading}
monitorCount={summary?.totalMonitors ?? 0}
/>
<Filter
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
setToFilterStatus={setToFilterStatus}
handleReset={handleReset}
/>
<SearchComponent
monitors={monitors}
onSearchChange={setSearch}
setIsSearching={setIsSearching}
/>
</Stack>
<MonitorsTable
isLoading={isLoading}
monitors={monitors}
isAdmin={isAdmin}
handleActionMenuDelete={handleActionMenuDelete}
isSearching={isSearching}
/>
<Pagination
itemCount={summary?.totalMonitors}
paginationLabel={t("monitors")}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
/>
</Stack>
</PageStateWrapper>
</TabPanel>
</TabContext>
</Stack>
</>
);
};
+35
View File
@@ -54,6 +54,35 @@ const networkInterfaceSchema = mongoose.Schema({
fifo_out: { type: Number, default: 0 },
});
const portSchema = mongoose.Schema({
port: { type: String },
protocol: { type: String },
});
const healthSchema = mongoose.Schema({
healthy: { type: Boolean, default: false },
source: { type: String, default: "" },
message: { type: String, default: "" },
});
const containerSchema = mongoose.Schema({
container_id: { type: String, default: "" },
container_name: { type: String, default: "" },
status: { type: String, default: "" },
health: {
type: healthSchema,
default: () => ({}),
},
running: { type: Boolean, default: false },
base_image: { type: String, default: "" },
exposed_ports: {
type: [portSchema],
default: () => [],
},
started_at: { type: Number, default: 0 },
finished_at: { type: Number, default: 0 },
});
const CheckSchema = new mongoose.Schema(
{
// Common fields
@@ -148,6 +177,12 @@ const CheckSchema = new mongoose.Schema(
default: () => [],
},
// Docker fields
docker: {
type: [containerSchema],
default: () => [],
},
// PageSpeed fields
accessibility: {
type: Number,
@@ -286,7 +286,7 @@ class NetworkService {
}
}
async requestDocker(monitor) {
async requestLocalDocker(monitor) {
try {
if (!monitor.url) {
throw new Error("Monitor URL is required");
@@ -330,7 +330,7 @@ class NetworkService {
this.logger.warn({
message: `No container found for "${monitor.url}".`,
service: this.SERVICE_NAME,
method: "requestDocker",
method: "requestLocalDocker",
details: { url: monitor.url },
});
@@ -350,7 +350,7 @@ class NetworkService {
this.logger.warn({
message: `Ambiguous container match for "${monitor.url}". Matched by: ${matchTypes.join(", ")}. Using ${exactIdMatch ? "exact ID" : exactNameMatch ? "exact name" : "partial ID"} match.`,
service: this.SERVICE_NAME,
method: "requestDocker",
method: "requestLocalDocker",
details: { url: monitor.url },
});
dockerResponse.status = 404;
@@ -375,6 +375,112 @@ class NetworkService {
}
return dockerResponse;
} catch (err) {
err.service = this.SERVICE_NAME;
err.method = "requestLocalDocker";
throw err;
}
}
async requestCaptureDocker(monitor) {
try {
if (!monitor.url) {
throw new Error("Monitor URL is required");
}
const dockerResponse = {
monitorId: monitor._id,
teamId: monitor.teamId,
type: monitor.type,
};
// Build Capture API URL
const captureUrl = new URL(monitor.url);
const apiUrl = `${captureUrl.protocol}//${captureUrl.host}/api/v1/metrics/docker`;
// Make request to Capture API
const { response, responseTime, error } = await this.timeRequest(async () => {
return await this.got(apiUrl, {
headers: {
Authorization: `Bearer ${monitor.secret || ""}`,
},
timeout: { request: 10000 },
responseType: "json",
});
});
dockerResponse.responseTime = responseTime;
// Handle errors
if (error) {
const statusCode = error.response?.statusCode || this.NETWORK_ERROR;
dockerResponse.status = false;
dockerResponse.code = statusCode;
if (statusCode === 401) {
dockerResponse.message = "Invalid API secret for Capture server";
} else if (statusCode === 404) {
dockerResponse.message = "Docker monitoring not available (Capture version too old or endpoint not found)";
} else if (statusCode === 500) {
dockerResponse.message = "Docker daemon unavailable on Capture server";
} else {
dockerResponse.message = error.message || "Failed to fetch Docker metrics from Capture";
}
return dockerResponse;
}
// Parse response
const payload = response.body || response;
const { data, capture, errors } = payload;
// Check for errors array
if (errors && errors.length > 0) {
// Check if it's a critical error (docker.client)
const dockerClientError = errors.find((e) => e.metric && e.metric.includes("docker.client"));
if (dockerClientError) {
dockerResponse.status = false;
dockerResponse.code = 500;
dockerResponse.message = dockerClientError.err || "Docker daemon unavailable";
return dockerResponse;
}
}
// Check if we have container data
if (!data || !Array.isArray(data) || data.length === 0) {
dockerResponse.status = false;
dockerResponse.code = 404;
dockerResponse.message = "No containers found on Capture server";
dockerResponse.payload = { data: [], capture, errors: errors || [] };
return dockerResponse;
}
// Successful response - store all containers
dockerResponse.status = true;
dockerResponse.code = 200;
dockerResponse.message = `Found ${data.length} container(s) on Capture server`;
dockerResponse.payload = { data, capture, errors: errors || [] };
return dockerResponse;
} catch (err) {
err.service = this.SERVICE_NAME;
err.method = "requestCaptureDocker";
throw err;
}
}
async requestDocker(monitor) {
try {
if (!monitor.url) {
throw new Error("Monitor URL is required");
}
// Check if URL is HTTP/HTTPS (Capture API) or container name/ID (local)
if (monitor.url.startsWith("http://") || monitor.url.startsWith("https://")) {
return await this.requestCaptureDocker(monitor);
} else {
return await this.requestLocalDocker(monitor);
}
} catch (err) {
err.service = this.SERVICE_NAME;
err.method = "requestDocker";
@@ -275,6 +275,13 @@ class StatusService {
check.capture = payload?.capture ?? {};
check.net = net ?? {};
}
if (type === "docker") {
const { data, capture, errors } = payload ?? {};
check.docker = data ?? [];
check.errors = errors ?? [];
check.capture = capture ?? {};
}
return check;
};