check card details

This commit is contained in:
Alex Holliday committed 2025-12-23 00:34:40 +00:00
1 parent 9f32eb6362
commit 92fd405395
8 files changed
+307 -197

No files matched your search

@@ -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 (
<BaseBox p={4} flex={1} height={"100%"}>
<Stack spacing={8} sx={{ width: "100%" }}>
<Stack>
<Typography>Check details</Typography>
<Typography variant="body2">{`Total request time: ${getPhaseMs("total")}`}</Typography>
{(() => {
const href = `/${getMonitorPath(check.metadata.type)}/${check.metadata.monitorId}`;
return href ? (
<Typography variant="body2" sx={{ mt: 0.5 }}>
<Link
component={RouterLink}
to={href}
underline="hover"
color="primary"
>
View monitor details
</Link>
</Typography>
) : null;
})()}
</Stack>
{/* Basic info section */}
<Grid container spacing={2}>
<Grid
size={12}
borderBottom={1}
borderColor={theme.palette.divider}
mt={4}
mb={2}
>
<Typography>Basic check info</Typography>
</Grid>
<Grid size={6} sx={{ display: "flex", alignItems: "center" }}>
<Typography variant="body1" color="text.secondary">
Status
</Typography>
</Grid>
<Grid size={6} sx={{ display: "flex", alignItems: "center" }}>
<Box>
<StatusLabel status={status} />
</Box>
</Grid>
<Grid size={6} sx={{ display: "flex", alignItems: "center" }}>
<Typography variant="body1" color="text.secondary">
HTTP Code
</Typography>
</Grid>
<Grid size={6} sx={{ display: "flex", alignItems: "center" }}>
<Box>
<ValueLabel value={httpValue} text={httpText} />
</Box>
</Grid>
<Grid size={6}>
<Typography variant="body1" color="text.secondary">
Type
</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body2">{typeText}</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body1" color="text.secondary">
Check time
</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body2">{checkedAtText}</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body1" color="text.secondary">
Message
</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body2">{messageText}</Typography>
</Grid>
</Grid>
{/* Timing phases section */}
<Grid container spacing={2}>
<Grid
size={12}
borderBottom={1}
borderColor={theme.palette.divider}
mb={4}
mt={2}
>
<Typography>Timing phases</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body1" color="text.secondary">
Wait
</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body2">{getPhaseMs("wait")}</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body1" color="text.secondary">
DNS
</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body2">{getPhaseMs("dns")}</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body1" color="text.secondary">
TCP
</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body2">{getPhaseMs("tcp")}</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body1" color="text.secondary">
TLS
</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body2">{getPhaseMs("tls")}</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body1" color="text.secondary">
Request
</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body2">{getPhaseMs("request")}</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body1" color="text.secondary">
First Byte
</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body2">{getPhaseMs("firstByte")}</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body1" color="text.secondary">
Download
</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body2">{getPhaseMs("download")}</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body1" color="text.secondary">
Total
</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body2">{getPhaseMs("total")}</Typography>
</Grid>
</Grid>
</Stack>
</BaseBox>
);
};
@@ -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 (
<BaseBox p={4} flex={1} height={"100%"}>
<Stack spacing={2} sx={{ width: "100%" }}>
<Typography>Check details</Typography>
<Typography variant="body2">
{`Total request time: ${check.timings.phases.total} ms`}
</Typography>
<Grid container spacing={2}>
<Grid size={6} sx={{ display: "flex", alignItems: "center" }}>
<Typography variant="body1" color="text.secondary">
Status
</Typography>
</Grid>
<Grid size={6} sx={{ display: "flex", alignItems: "center" }}>
<Box>
<StatusLabel status={check.status as MonitorStatus} />
</Box>
</Grid>
<Grid size={6} sx={{ display: "flex", alignItems: "center" }}>
<Typography variant="body1" color="text.secondary">
HTTP Code
</Typography>
</Grid>
<Grid size={6} sx={{ display: "flex", alignItems: "center" }}>
<Box>
<ValueLabel
value={
check.httpStatusCode < 300
? "positive"
: check.httpStatusCode < 400
? "neutral"
: "negative"
}
text={String(check.httpStatusCode ?? "-")}
/>
</Box>
</Grid>
<Grid size={6}>
<Typography variant="body1" color="text.secondary">
Type
</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body2">
{check.metadata?.type || "-"}
</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body2">{Math.round(totalMs)} ms</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body1" color="text.secondary">
Checked At
</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body2">
{formatDateWithTz(
check.createdAt,
"ddd, MMM D, YYYY, HH:mm A",
uiTimezone
)}
</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body1" color="text.secondary">
Message
</Typography>
</Grid>
<Grid size={6}>
<Typography variant="body2">{check.message || "-"}</Typography>
</Grid>
</Grid>
</Stack>
</BaseBox>
);
};
+27 -67
View File
@@ -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 (
<text
x={x}
y={y}
fill={strokeColor}
textAnchor={x > cx ? "start" : "end"}
dominantBaseline="central"
style={{ fontSize: 12 }}
>
{text}
</text>
);
};
const renderLegend = () => (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{segments.map((s) => (
<div
key={s.name}
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<span
style={{
display: "inline-block",
width: 14,
height: 2,
backgroundColor: strokeColor,
}}
/>
<span style={{ fontSize: 12, color: theme.palette.text.secondary }}>
{s.name}: {Math.round(Number(s.value) || 0)} ms
</span>
</div>
))}
</div>
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const segments = buildTimingSegments(
check?.timings?.phases,
strokeColor,
hoveredIndex,
theme.palette.primary.main
);
if (!segments.length) return null;
return (
<BaseBox p={4} flex={1} height={"100%"}>
<Stack spacing={2} sx={{ width: "100%", height: 260 }}>
<Stack spacing={2} sx={{ width: "100%", height: 400 }}>
<Typography>Request timings</Typography>
<Typography variant="body2">
{`Total request time: ${check.timings.phases.total} ms`}
{`Total request time: ${Math.floor(Number(check?.timings?.phases?.total ?? 0))} ms`}
</Typography>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
@@ -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)}
/>
<Tooltip
formatter={(value) => `${Math.round(value as number)} ms`}
formatter={(value) => `${Math.floor(value as number)} ms`}
/>
<Legend
layout="vertical"
verticalAlign="middle"
align="right"
content={renderLegend}
wrapperStyle={{ paddingLeft: 12 }}
layout="horizontal"
verticalAlign="bottom"
align="center"
payload={segments.map((s) => ({
id: s.name,
value: `${s.name}: ${Math.floor(Number(s.value) || 0)} ms`,
type: "line",
color: strokeColor,
}))}
/>
</PieChart>
</ResponsiveContainer>
+1 -1
View File
@@ -1,2 +1,2 @@
export { TimingsChart } from "./ChartTiming";
export { CheckDetailsCard } from "./CardChecktDetails";
export { CheckDetailsCard } from "./CardCheckDetails";
@@ -123,7 +123,7 @@ export const MonitorBasePageWithStates = ({
const showLoading = loading && (!items || items.length === 0);
if (isEmpty(items)) {
if (!loading && isEmpty(items)) {
return (
<EmptyMonitorFallback
page={page}
+3 -1
View File
@@ -1,3 +1,5 @@
import type { MonitorType } from "./monitor";
export interface CheckTimingPhases {
wait: number;
dns: number;
@@ -105,7 +107,7 @@ export interface ICheck {
_id: string;
metadata: {
monitorId: string;
type: string;
type: MonitorType;
teamId: string;
};
status: string;
+26 -4
View File
@@ -179,10 +179,14 @@
}
},
"/auth/logout": { "post": { "responses": { "200": { "description": "Logged out" } } } },
"/auth/me": { "get": { "responses": { "200": { "description": "Current user" } } } },
"/auth/cleanup": { "post": { "responses": { "200": { "description": "Cleanup executed" } } } },
"/auth/cleanup-monitors": { "post": { "responses": { "200": { "description": "Monitor cleanup executed" } } } },
"/billing/plans": { "get": { "responses": { "200": { "description": "Available plans" } } } },
"/billing/subscribe": { "post": { "responses": { "200": { "description": "Subscribed" } } } },
"/billing/cancel": { "post": { "responses": { "200": { "description": "Canceled" } } } },
"/billing/confirm": { "get": { "responses": { "200": { "description": "Subscription confirmed" } } } },
"/monitors": {
"get": {
"parameters": [
@@ -282,6 +286,9 @@
"security": []
}
},
"/diagnostic/logs": { "get": { "responses": { "200": { "description": "Logs" } } } },
"/diagnostic/jobs": { "get": { "responses": { "200": { "description": "Jobs" } } } },
"/diagnostic/env": { "get": { "responses": { "200": { "description": "Environment" } } } },
"/checks": {
"get": {
@@ -295,6 +302,14 @@
"responses": { "200": { "description": "Checks list" } }
}
},
"/checks/{id}": {
"get": {
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "Check by id" }, "404": { "description": "Not found" } }
}
},
"/incidents": {
"get": {
@@ -310,10 +325,11 @@
"/incidents/{id}/resolve": {
"patch": {
"parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true } } } },
"responses": { "200": { "description": "Resolved" } }
}
},
"/incidents/export": { "get": { "responses": { "200": { "description": "Export incidents" } } } },
"/incidents/{id}": { "get": { "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Incident" } } } },
"/invite": {
"get": { "responses": { "200": { "description": "Invites list" } } },
@@ -399,9 +415,15 @@
"post": { "responses": { "200": { "description": "Created" } } }
},
"/team-members/{id}": {
"get": { "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Member" } } },
"get": { "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Team member" } } },
"patch": { "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Updated" } } },
"delete": { "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Deleted" } } }
}
},
"/me": { "get": { "responses": { "200": { "description": "Current user" } } } },
"/me/entitlements": { "get": { "responses": { "200": { "description": "Entitlements" } } } },
"/me/permissions": { "get": { "responses": { "200": { "description": "Permissions" } } } },
"/stripe/webhook": { "post": { "responses": { "200": { "description": "Webhook received" } } } }
}
}
+22 -7
View File
@@ -12,6 +12,7 @@ const indexLogger = getChildLogger("index");
import swaggerUi from "swagger-ui-express";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const createApp = async () => {
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}`);