From 92fd40539508bf6ed43462fb4c665211cb03a4ff Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Tue, 23 Dec 2025 00:34:40 +0000 Subject: [PATCH] check card details --- .../components/checks/CardCheckDetails.tsx | 227 ++++++++++++++++++ .../components/checks/CardChecktDetails.tsx | 116 --------- client/src/components/checks/ChartTiming.tsx | 94 +++----- client/src/components/checks/index.tsx | 2 +- .../components/design-elements/BasePage.tsx | 2 +- client/src/types/check.ts | 4 +- server/openapi.json | 30 ++- server/src/index.ts | 29 ++- 8 files changed, 307 insertions(+), 197 deletions(-) create mode 100644 client/src/components/checks/CardCheckDetails.tsx delete mode 100644 client/src/components/checks/CardChecktDetails.tsx diff --git a/client/src/components/checks/CardCheckDetails.tsx b/client/src/components/checks/CardCheckDetails.tsx new file mode 100644 index 000000000..3b8811ec5 --- /dev/null +++ b/client/src/components/checks/CardCheckDetails.tsx @@ -0,0 +1,227 @@ +import Stack from "@mui/material/Stack"; +import Grid from "@mui/material/Grid"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Link from "@mui/material/Link"; +import { Link as RouterLink } from "react-router-dom"; +import { StatusLabel, ValueLabel } from "@/components/design-elements"; +import { BaseBox } from "@/components/design-elements"; + +import { useAppSelector } from "@/hooks/AppHooks"; +import type { ICheck } from "@/types/check"; +import type { MonitorStatus } from "@/types/monitor"; +import { formatDateWithTz } from "@/utils/TimeUtils"; +import { useTheme } from "@mui/material/styles"; +import { getMonitorPath } from "@/utils/MonitorUtils"; + +type CheckDetailsCardProps = { + check: ICheck; +}; + +export const CheckDetailsCard = ({ check }: CheckDetailsCardProps) => { + const theme = useTheme(); + const uiTimezone = useAppSelector((state: any) => state.ui.timezone); + const phases = check?.timings?.phases; + const phaseKeys = [ + "wait", + "dns", + "tcp", + "tls", + "request", + "firstByte", + "download", + "total", + ] as const; + + const status = + (check?.status as MonitorStatus) ?? ("initializing" as MonitorStatus); + const httpStatusCode = Number(check?.httpStatusCode ?? NaN); + const httpValue: "positive" | "neutral" | "negative" = Number.isFinite( + httpStatusCode + ) + ? httpStatusCode < 300 + ? "positive" + : httpStatusCode < 400 + ? "neutral" + : "negative" + : "neutral"; + const httpText = Number.isFinite(httpStatusCode) + ? String(httpStatusCode) + : "-"; + const typeText = check?.metadata?.type ?? "-"; + const checkedAtText = check?.createdAt + ? formatDateWithTz(check.createdAt, "ddd, MMM D, YYYY, HH:mm A", uiTimezone) + : "-"; + const messageText = check?.message ?? "-"; + const getPhaseMs = (key: (typeof phaseKeys)[number]) => { + const v = Number((phases as any)?.[key] ?? NaN); + return Number.isFinite(v) ? `${Math.floor(Math.max(0, v))} ms` : "-"; + }; + + return ( + + + + Check details + {`Total request time: ${getPhaseMs("total")}`} + {(() => { + const href = `/${getMonitorPath(check.metadata.type)}/${check.metadata.monitorId}`; + return href ? ( + + + View monitor details + + + ) : null; + })()} + + {/* Basic info section */} + + + Basic check info + + + + Status + + + + + + + + + + HTTP Code + + + + + + + + + + Type + + + + {typeText} + + + + + Check time + + + + {checkedAtText} + + + + Message + + + + {messageText} + + + {/* Timing phases section */} + + + Timing phases + + + + + Wait + + + + {getPhaseMs("wait")} + + + + + DNS + + + + {getPhaseMs("dns")} + + + + + TCP + + + + {getPhaseMs("tcp")} + + + + + TLS + + + + {getPhaseMs("tls")} + + + + + Request + + + + {getPhaseMs("request")} + + + + + First Byte + + + + {getPhaseMs("firstByte")} + + + + + Download + + + + {getPhaseMs("download")} + + + + + Total + + + + {getPhaseMs("total")} + + + + + ); +}; diff --git a/client/src/components/checks/CardChecktDetails.tsx b/client/src/components/checks/CardChecktDetails.tsx deleted file mode 100644 index 245cb9b20..000000000 --- a/client/src/components/checks/CardChecktDetails.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import Stack from "@mui/material/Stack"; -import Grid from "@mui/material/Grid"; -import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; -import { StatusLabel, ValueLabel } from "@/components/design-elements"; -import { BaseBox } from "@/components/design-elements"; - -import { useAppSelector } from "@/hooks/AppHooks"; -import type { ICheck } from "@/types/check"; -import type { MonitorStatus } from "@/types/monitor"; -import { formatDateWithTz } from "@/utils/TimeUtils"; - -type CheckDetailsCardProps = { - check: ICheck; -}; - -export const CheckDetailsCard = ({ check }: CheckDetailsCardProps) => { - const uiTimezone = useAppSelector((state: any) => state.ui.timezone); - const phases = check?.timings?.phases; - const phaseKeys = [ - "wait", - "dns", - "tcp", - "tls", - "request", - "firstByte", - "download", - ] as const; - const totalMs = phases - ? phaseKeys.reduce( - (sum, key) => sum + Math.max(0, Number((phases as any)[key] ?? 0)), - 0 - ) - : Number(check.responseTime ?? 0); - - return ( - - - Check details - - {`Total request time: ${check.timings.phases.total} ms`} - - - - - - Status - - - - - - - - - - - - HTTP Code - - - - - - - - - - Type - - - - - {check.metadata?.type || "-"} - - - - - {Math.round(totalMs)} ms - - - - Checked At - - - - - {formatDateWithTz( - check.createdAt, - "ddd, MMM D, YYYY, HH:mm A", - uiTimezone - )} - - - - - Message - - - - {check.message || "-"} - - - - - ); -}; diff --git a/client/src/components/checks/ChartTiming.tsx b/client/src/components/checks/ChartTiming.tsx index 3eb3b1c5c..b935cd13d 100644 --- a/client/src/components/checks/ChartTiming.tsx +++ b/client/src/components/checks/ChartTiming.tsx @@ -3,6 +3,7 @@ import { PieChart, Pie, ResponsiveContainer, Tooltip, Legend } from "recharts"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { alpha, useTheme } from "@mui/material/styles"; +import { useState } from "react"; import { BaseBox } from "@/components/design-elements"; type TimingsChartProps = { check: ICheck | null }; @@ -26,13 +27,15 @@ const PHASE_ORDER: (keyof CheckTimingPhases)[] = [ const buildTimingSegments = ( phases: CheckTimingPhases | undefined, - stroke: string + stroke: string, + hoveredIndex: number | null, + hoverFill: string ): TimingSegment[] => { if (!phases) return []; - return PHASE_ORDER.map((key) => ({ + return PHASE_ORDER.map((key, i) => ({ name: String(key), value: Math.max(0, Number((phases as any)[key] ?? 0)), - fill: "transparent", + fill: hoveredIndex === i ? hoverFill : "transparent", stroke, })); }; @@ -42,68 +45,21 @@ export const TimingsChart = ({ check }: TimingsChartProps) => { const theme = useTheme(); const strokeColor = alpha(theme.palette.primary.main, 0.8); - const segments = buildTimingSegments(check?.timings?.phases, strokeColor); - if (!segments.length) return null; - const LABEL_THRESHOLD = 0.05; - - const renderLabel = ({ - cx, - cy, - midAngle, - outerRadius, - name, - value, - percent, - }: any) => { - if (!percent || percent < LABEL_THRESHOLD) return null; - const RAD = Math.PI / 180; - const r = (outerRadius || 0) + 24; - const x = cx + r * Math.cos(-midAngle * RAD); - const y = cy + r * Math.sin(-midAngle * RAD); - const text = `${name}: ${Math.round(Number(value) || 0)} ms`; - return ( - cx ? "start" : "end"} - dominantBaseline="central" - style={{ fontSize: 12 }} - > - {text} - - ); - }; - - const renderLegend = () => ( -
- {segments.map((s) => ( -
- - - {s.name}: {Math.round(Number(s.value) || 0)} ms - -
- ))} -
+ const [hoveredIndex, setHoveredIndex] = useState(null); + const segments = buildTimingSegments( + check?.timings?.phases, + strokeColor, + hoveredIndex, + theme.palette.primary.main ); + if (!segments.length) return null; return ( - + Request timings - {`Total request time: ${check.timings.phases.total} ms`} + {`Total request time: ${Math.floor(Number(check?.timings?.phases?.total ?? 0))} ms`} @@ -113,18 +69,22 @@ export const TimingsChart = ({ check }: TimingsChartProps) => { nameKey="name" stroke="none" minAngle={4} - label={renderLabel} - labelLine={{ stroke: strokeColor, strokeWidth: 1, opacity: 0.6 }} + onMouseEnter={(_, index: number) => setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} /> `${Math.round(value as number)} ms`} + formatter={(value) => `${Math.floor(value as number)} ms`} /> ({ + id: s.name, + value: `${s.name}: ${Math.floor(Number(s.value) || 0)} ms`, + type: "line", + color: strokeColor, + }))} /> diff --git a/client/src/components/checks/index.tsx b/client/src/components/checks/index.tsx index e594e2429..f36a2fdf8 100644 --- a/client/src/components/checks/index.tsx +++ b/client/src/components/checks/index.tsx @@ -1,2 +1,2 @@ export { TimingsChart } from "./ChartTiming"; -export { CheckDetailsCard } from "./CardChecktDetails"; +export { CheckDetailsCard } from "./CardCheckDetails"; diff --git a/client/src/components/design-elements/BasePage.tsx b/client/src/components/design-elements/BasePage.tsx index 57bf017db..a5ef25d7f 100644 --- a/client/src/components/design-elements/BasePage.tsx +++ b/client/src/components/design-elements/BasePage.tsx @@ -123,7 +123,7 @@ export const MonitorBasePageWithStates = ({ const showLoading = loading && (!items || items.length === 0); - if (isEmpty(items)) { + if (!loading && isEmpty(items)) { return ( { await connectDatabase(); @@ -38,13 +39,28 @@ const createApp = async () => { const routes = initRoutes(controllers, app); // expose services for route-level access (e.g., in me routes) app.set("services", services); - // Swagger UI at /api-docs + try { - // Load OpenAPI doc from project root within server folder - const openapiPath = path.resolve(process.cwd(), "openapi.json"); - const openapiJson = JSON.parse(fs.readFileSync(openapiPath, "utf-8")); - app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(openapiJson)); - indexLogger.info("Swagger UI available at /api-docs"); + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.resolve(process.cwd(), "openapi.json"), + path.resolve(process.cwd(), "server/openapi.json"), + path.resolve(__dirname, "../openapi.json"), + ]; + const existing = candidates.find((p) => fs.existsSync(p)); + if (!existing) { + indexLogger.error( + "OpenAPI file not found. Checked: " + candidates.join(", ") + ); + } else { + const openapiJson = JSON.parse(fs.readFileSync(existing, "utf-8")); + const setup = swaggerUi.setup(openapiJson); + app.use("/api-docs", swaggerUi.serve, setup); + + indexLogger.info( + `Swagger UI available at /api-docs and /api/v1/api-docs (spec: ${existing})` + ); + } } catch (e) { indexLogger.error("Failed to load OpenAPI document for Swagger UI", e); } @@ -53,7 +69,6 @@ const createApp = async () => { status: "OK", }); }); - const port = 52345; app.listen(port, "0.0.0.0", () => { indexLogger.info(`Server is running on http://localhost:${port}`);