Skip to content

Commit 266d147

Browse files
committed
feat(admin): show integration health-history trends in detail view
Wire the existing GET /admin/integrations/:id/health/history endpoint into IntegrationDetail with a recharts LineChart of hourly uptime % (fixed 0-100 axis), avgResponseTime in the tooltip, and a 24h/7d toggle. Reuses the already-present recharts dependency; no API changes.
1 parent 6ddd88f commit 266d147

3 files changed

Lines changed: 312 additions & 0 deletions

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
"use client";
2+
3+
import Box from "@mui/material/Box";
4+
import Skeleton from "@mui/material/Skeleton";
5+
import Stack from "@mui/material/Stack";
6+
import ToggleButton from "@mui/material/ToggleButton";
7+
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
8+
import Typography from "@mui/material/Typography";
9+
import { useQuery } from "@tanstack/react-query";
10+
import { useState } from "react";
11+
import {
12+
CartesianGrid,
13+
Line,
14+
LineChart,
15+
ResponsiveContainer,
16+
Tooltip,
17+
XAxis,
18+
YAxis,
19+
} from "recharts";
20+
import { useEnv } from "@/lib/EnvProvider";
21+
22+
interface HealthBucket {
23+
hour: string;
24+
total: number;
25+
healthy: number;
26+
uptimePercent: number;
27+
avgResponseTime: number | null;
28+
}
29+
30+
interface HealthHistoryResponse {
31+
integrationId: string;
32+
hours: number;
33+
timeline: HealthBucket[];
34+
}
35+
36+
type HoursWindow = 24 | 168;
37+
38+
/** Format an ISO-ish bucket timestamp for the X axis: HH:00 for 24h, weekday for 7d. */
39+
function formatBucketLabel(hour: string, hours: HoursWindow): string {
40+
const d = new Date(hour);
41+
if (Number.isNaN(d.getTime())) return hour;
42+
if (hours === 24) {
43+
return `${String(d.getHours()).padStart(2, "0")}:00`;
44+
}
45+
return d.toLocaleDateString(undefined, { weekday: "short" });
46+
}
47+
48+
function HealthTooltip({
49+
active,
50+
payload,
51+
hours,
52+
}: {
53+
active?: boolean;
54+
payload?: Array<{ payload: HealthBucket }>;
55+
hours: HoursWindow;
56+
}) {
57+
if (!active || !payload?.[0]) return null;
58+
const bucket = payload[0].payload;
59+
const responseTime = bucket.avgResponseTime != null ? `${bucket.avgResponseTime} ms` : "—";
60+
61+
return (
62+
<Box
63+
sx={{
64+
bgcolor: "background.paper",
65+
border: "1px solid",
66+
borderColor: "divider",
67+
borderRadius: "6px",
68+
px: 1.25,
69+
py: 0.75,
70+
boxShadow: "0 2px 8px rgba(0,0,0,0.12)",
71+
}}
72+
>
73+
<Typography variant="caption" sx={{ fontWeight: 600, display: "block" }}>
74+
{formatBucketLabel(bucket.hour, hours)}
75+
</Typography>
76+
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
77+
Uptime: {bucket.uptimePercent}%
78+
</Typography>
79+
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
80+
Avg response: {responseTime}
81+
</Typography>
82+
<Typography variant="caption" sx={{ color: "text.secondary", display: "block" }}>
83+
Checks: {bucket.healthy}/{bucket.total}
84+
</Typography>
85+
</Box>
86+
);
87+
}
88+
89+
interface HealthHistoryChartProps {
90+
integrationId: string;
91+
}
92+
93+
export function HealthHistoryChart({ integrationId }: HealthHistoryChartProps) {
94+
const env = useEnv();
95+
const apiUrl = env.apiUrl;
96+
const [hours, setHours] = useState<HoursWindow>(24);
97+
98+
const { data, isLoading } = useQuery<HealthBucket[]>({
99+
queryKey: ["admin", "integrations", integrationId, "health-history", hours],
100+
queryFn: async () => {
101+
const res = await fetch(
102+
`${apiUrl}/api/admin/integrations/${integrationId}/health/history?hours=${hours}`,
103+
{ credentials: "include" },
104+
);
105+
if (!res.ok) throw new Error("Failed to load health history");
106+
const body: HealthHistoryResponse = await res.json();
107+
return body.timeline ?? [];
108+
},
109+
});
110+
111+
const buckets = data ?? [];
112+
113+
return (
114+
<Stack sx={{ gap: 1.5 }}>
115+
<Stack
116+
direction="row"
117+
sx={{ alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 1 }}
118+
>
119+
<Typography variant="subtitle2" sx={{ color: "text.secondary" }}>
120+
Uptime over time
121+
</Typography>
122+
<ToggleButtonGroup
123+
size="small"
124+
exclusive
125+
value={hours}
126+
onChange={(_, value: HoursWindow | null) => {
127+
if (value != null) setHours(value);
128+
}}
129+
aria-label="Health history time window"
130+
>
131+
<ToggleButton value={24} aria-label="Last 24 hours">
132+
24h
133+
</ToggleButton>
134+
<ToggleButton value={168} aria-label="Last 7 days">
135+
7d
136+
</ToggleButton>
137+
</ToggleButtonGroup>
138+
</Stack>
139+
{isLoading ? (
140+
<Skeleton variant="rounded" height={220} />
141+
) : buckets.length === 0 ? (
142+
<Typography variant="body2" sx={{ color: "text.secondary", py: 4, textAlign: "center" }}>
143+
No health history yet
144+
</Typography>
145+
) : (
146+
<Box sx={{ width: "100%", height: 220, minWidth: 0 }}>
147+
<ResponsiveContainer width="100%" height={220}>
148+
<LineChart data={buckets} margin={{ top: 8, right: 8, bottom: 0, left: -12 }}>
149+
<CartesianGrid horizontal vertical={false} strokeDasharray="3 3" stroke="#E0E0E0" />
150+
<XAxis
151+
dataKey="hour"
152+
tickFormatter={(value: string) => formatBucketLabel(value, hours)}
153+
tick={{ fontSize: 10, fill: "#999" }}
154+
axisLine={false}
155+
tickLine={false}
156+
minTickGap={24}
157+
/>
158+
<YAxis
159+
domain={[0, 100]}
160+
tickFormatter={(value: number) => `${value}`}
161+
tick={{ fontSize: 10, fill: "#999" }}
162+
axisLine={false}
163+
tickLine={false}
164+
width={36}
165+
unit="%"
166+
/>
167+
<Tooltip
168+
content={<HealthTooltip hours={hours} />}
169+
cursor={{ stroke: "#999", strokeDasharray: "3 3" }}
170+
/>
171+
<Line
172+
type="monotone"
173+
dataKey="uptimePercent"
174+
stroke="var(--omx-teal, #1A73E8)"
175+
strokeWidth={2}
176+
dot={false}
177+
activeDot={{
178+
r: 4,
179+
fill: "var(--omx-teal, #1A73E8)",
180+
stroke: "#fff",
181+
strokeWidth: 2,
182+
}}
183+
isAnimationActive={false}
184+
/>
185+
</LineChart>
186+
</ResponsiveContainer>
187+
</Box>
188+
)}
189+
</Stack>
190+
);
191+
}

apps/web/src/components/admin/integrations/IntegrationDetail.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { useAdminToast } from "../shared/AdminToast";
3838
import { MetaRow } from "../shared/MetaRow";
3939
import { ConfigSchemaForm } from "./ConfigSchemaForm";
4040
import { DomainChip } from "./DomainChip";
41+
import { HealthHistoryChart } from "./HealthHistoryChart";
4142
import { IntegrationStatusDot } from "./IntegrationStatusDot";
4243
import { RequiredServicesPanel } from "./RequiredServicesPanel";
4344
import { SetCredentialDialog } from "./SetCredentialDialog";
@@ -975,6 +976,22 @@ function HealthTab({
975976
</CardContent>
976977
</Card>
977978
)}
979+
{data.hasHealthCheck && (
980+
<Card variant="outlined">
981+
<CardContent>
982+
<Typography
983+
variant="subtitle2"
984+
gutterBottom
985+
sx={{
986+
color: "text.secondary",
987+
}}
988+
>
989+
Health History
990+
</Typography>
991+
<HealthHistoryChart integrationId={integrationId} />
992+
</CardContent>
993+
</Card>
994+
)}
978995
</Stack>
979996
);
980997
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// @vitest-environment jsdom
2+
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import { createQueryWrapper, render, screen, userEvent, waitFor } from "@/test";
5+
6+
vi.mock("@/lib/EnvProvider", () => ({
7+
useEnv: () => ({ apiUrl: "http://test.local" }),
8+
}));
9+
10+
// recharts' ResponsiveContainer measures its parent with ResizeObserver, which
11+
// reports 0x0 in jsdom — the chart then renders nothing. Force a fixed size so
12+
// the chart mounts and data-driven labels (tooltip, ticks) are assertable.
13+
vi.mock("recharts", async (importOriginal) => {
14+
const actual = await importOriginal<typeof import("recharts")>();
15+
return {
16+
...actual,
17+
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
18+
<div style={{ width: 600, height: 220 }} data-testid="chart-container">
19+
<actual.ResponsiveContainer width={600} height={220}>
20+
{children}
21+
</actual.ResponsiveContainer>
22+
</div>
23+
),
24+
};
25+
});
26+
27+
const fetchMock = vi.fn();
28+
vi.stubGlobal("fetch", fetchMock);
29+
30+
import { HealthHistoryChart } from "../HealthHistoryChart";
31+
32+
const TIMELINE = [
33+
{
34+
hour: "2026-06-17T08:00:00.000Z",
35+
total: 6,
36+
healthy: 6,
37+
uptimePercent: 100,
38+
avgResponseTime: 120,
39+
},
40+
{
41+
hour: "2026-06-17T09:00:00.000Z",
42+
total: 6,
43+
healthy: 3,
44+
uptimePercent: 50,
45+
avgResponseTime: null,
46+
},
47+
];
48+
49+
/** Build a response body matching the route shape: { integrationId, hours, timeline }. */
50+
function bodyFor(url: string) {
51+
const hours = new URL(url).searchParams.get("hours") ?? "24";
52+
return { integrationId: "demo", hours: Number(hours), timeline: TIMELINE };
53+
}
54+
55+
/** Query-string `hours` values across all fetch calls. */
56+
function requestedHours(): string[] {
57+
return fetchMock.mock.calls.map((c) => new URL(String(c[0])).searchParams.get("hours") ?? "");
58+
}
59+
60+
afterEach(() => {
61+
fetchMock.mockReset();
62+
});
63+
64+
describe("HealthHistoryChart", () => {
65+
it("renders the chart for a populated timeline", async () => {
66+
fetchMock.mockImplementation((...args: unknown[]) =>
67+
Promise.resolve({ ok: true, json: async () => bodyFor(String(args[0])) }),
68+
);
69+
70+
render(<HealthHistoryChart integrationId="demo" />, { wrapper: createQueryWrapper() });
71+
72+
// The chart container mounts once data arrives (no skeleton, no empty state).
73+
await screen.findByTestId("chart-container");
74+
expect(screen.queryByText("No health history yet")).toBeNull();
75+
// Default window queries 24 hours.
76+
expect(requestedHours()).toContain("24");
77+
});
78+
79+
it("re-queries with hours=168 when the 7d toggle is clicked", async () => {
80+
const user = userEvent.setup();
81+
fetchMock.mockImplementation((...args: unknown[]) =>
82+
Promise.resolve({ ok: true, json: async () => bodyFor(String(args[0])) }),
83+
);
84+
85+
render(<HealthHistoryChart integrationId="demo" />, { wrapper: createQueryWrapper() });
86+
await screen.findByTestId("chart-container");
87+
88+
await user.click(screen.getByRole("button", { name: "Last 7 days" }));
89+
90+
await waitFor(() => expect(requestedHours()).toContain("168"));
91+
});
92+
93+
it("shows an empty state when the timeline is empty", async () => {
94+
fetchMock.mockResolvedValue({
95+
ok: true,
96+
json: async () => ({ integrationId: "demo", hours: 24, timeline: [] }),
97+
});
98+
99+
render(<HealthHistoryChart integrationId="demo" />, { wrapper: createQueryWrapper() });
100+
101+
await screen.findByText("No health history yet");
102+
expect(screen.queryByTestId("chart-container")).toBeNull();
103+
});
104+
});

0 commit comments

Comments
 (0)