@checkstack/healthcheck-frontend 0.0.2 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/package.json +1 -1
- package/src/auto-charts/AutoChartGrid.tsx +377 -78
- package/src/auto-charts/index.ts +1 -1
- package/src/auto-charts/schema-parser.ts +122 -51
- package/src/components/AssertionBuilder.tsx +425 -0
- package/src/components/CollectorList.tsx +303 -0
- package/src/components/HealthCheckDiagram.tsx +39 -62
- package/src/components/HealthCheckEditor.tsx +37 -8
- package/src/components/HealthCheckLatencyChart.tsx +119 -59
- package/src/components/HealthCheckRunsTable.tsx +142 -6
- package/src/components/HealthCheckStatusTimeline.tsx +35 -52
- package/src/components/HealthCheckSystemOverview.tsx +208 -185
- package/src/hooks/useCollectors.ts +63 -0
- package/src/hooks/useHealthCheckData.ts +52 -33
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useState } from "react";
|
|
2
2
|
import { useApi, type SlotContext } from "@checkstack/frontend-api";
|
|
3
3
|
import { useSignal } from "@checkstack/signal-frontend";
|
|
4
4
|
import { healthCheckApiRef } from "../api";
|
|
@@ -23,28 +23,25 @@ import { ChevronDown, ChevronRight } from "lucide-react";
|
|
|
23
23
|
import { HealthCheckSparkline } from "./HealthCheckSparkline";
|
|
24
24
|
import { HealthCheckLatencyChart } from "./HealthCheckLatencyChart";
|
|
25
25
|
import { HealthCheckStatusTimeline } from "./HealthCheckStatusTimeline";
|
|
26
|
+
import { HealthCheckDiagram } from "./HealthCheckDiagram";
|
|
27
|
+
import { useHealthCheckData } from "../hooks/useHealthCheckData";
|
|
26
28
|
|
|
27
29
|
import type {
|
|
28
30
|
StateThresholds,
|
|
29
31
|
HealthCheckStatus,
|
|
30
32
|
} from "@checkstack/healthcheck-common";
|
|
31
|
-
import { HealthCheckDiagram } from "./HealthCheckDiagram";
|
|
32
33
|
|
|
33
34
|
type SlotProps = SlotContext<typeof SystemDetailsSlot>;
|
|
34
35
|
|
|
35
36
|
interface HealthCheckOverviewItem {
|
|
36
37
|
configurationId: string;
|
|
37
|
-
configurationName: string;
|
|
38
38
|
strategyId: string;
|
|
39
|
+
name: string;
|
|
40
|
+
state: HealthCheckStatus;
|
|
39
41
|
intervalSeconds: number;
|
|
40
|
-
|
|
41
|
-
status: HealthCheckStatus;
|
|
42
|
+
lastRunAt?: Date;
|
|
42
43
|
stateThresholds?: StateThresholds;
|
|
43
|
-
|
|
44
|
-
id: string;
|
|
45
|
-
status: HealthCheckStatus;
|
|
46
|
-
timestamp: Date;
|
|
47
|
-
}>;
|
|
44
|
+
recentStatusHistory: HealthCheckStatus[];
|
|
48
45
|
}
|
|
49
46
|
|
|
50
47
|
interface ExpandedRowProps {
|
|
@@ -67,47 +64,20 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
|
|
|
67
64
|
return { startDate: start, endDate: end };
|
|
68
65
|
});
|
|
69
66
|
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
>([]);
|
|
79
|
-
const [chartLoading, setChartLoading] = useState(true);
|
|
80
|
-
|
|
81
|
-
const fetchChartData = useCallback(() => {
|
|
82
|
-
setChartLoading(true);
|
|
83
|
-
api
|
|
84
|
-
.getHistory({
|
|
85
|
-
systemId,
|
|
86
|
-
configurationId: item.configurationId,
|
|
87
|
-
startDate: dateRange.startDate,
|
|
88
|
-
endDate: dateRange.endDate,
|
|
89
|
-
// Fetch up to 1000 data points for charts - enough for most time ranges
|
|
90
|
-
limit: 1000,
|
|
91
|
-
offset: 0,
|
|
92
|
-
})
|
|
93
|
-
.then((response) => {
|
|
94
|
-
setChartData(response.runs);
|
|
95
|
-
})
|
|
96
|
-
.finally(() => {
|
|
97
|
-
setChartLoading(false);
|
|
98
|
-
});
|
|
99
|
-
}, [
|
|
100
|
-
api,
|
|
67
|
+
// Use shared hook for chart data - handles both raw and aggregated modes
|
|
68
|
+
// and includes signal handling for automatic refresh
|
|
69
|
+
const {
|
|
70
|
+
context: chartContext,
|
|
71
|
+
loading: chartLoading,
|
|
72
|
+
isAggregated,
|
|
73
|
+
retentionConfig,
|
|
74
|
+
} = useHealthCheckData({
|
|
101
75
|
systemId,
|
|
102
|
-
item.configurationId,
|
|
103
|
-
|
|
104
|
-
dateRange
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
// Fetch chart data when date range changes
|
|
108
|
-
useEffect(() => {
|
|
109
|
-
fetchChartData();
|
|
110
|
-
}, [fetchChartData]);
|
|
76
|
+
configurationId: item.configurationId,
|
|
77
|
+
strategyId: item.strategyId,
|
|
78
|
+
dateRange,
|
|
79
|
+
limit: 1000,
|
|
80
|
+
});
|
|
111
81
|
|
|
112
82
|
// Paginated history for the table
|
|
113
83
|
const {
|
|
@@ -121,7 +91,6 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
|
|
|
121
91
|
systemId: string;
|
|
122
92
|
configurationId: string;
|
|
123
93
|
startDate?: Date;
|
|
124
|
-
endDate?: Date;
|
|
125
94
|
}) =>
|
|
126
95
|
api.getHistory({
|
|
127
96
|
systemId: params.systemId,
|
|
@@ -129,7 +98,7 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
|
|
|
129
98
|
limit: params.limit,
|
|
130
99
|
offset: params.offset,
|
|
131
100
|
startDate: params.startDate,
|
|
132
|
-
endDate
|
|
101
|
+
// Don't pass endDate - backend defaults to 'now' so new runs are included
|
|
133
102
|
}),
|
|
134
103
|
getItems: (response) => response.runs,
|
|
135
104
|
getTotal: (response) => response.total,
|
|
@@ -137,16 +106,15 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
|
|
|
137
106
|
systemId,
|
|
138
107
|
configurationId: item.configurationId,
|
|
139
108
|
startDate: dateRange.startDate,
|
|
140
|
-
endDate: dateRange.endDate,
|
|
141
109
|
},
|
|
142
110
|
defaultLimit: 10,
|
|
143
111
|
});
|
|
144
112
|
|
|
145
|
-
// Listen for realtime health check updates to refresh
|
|
113
|
+
// Listen for realtime health check updates to refresh history table
|
|
114
|
+
// Charts are refreshed automatically by useHealthCheckData
|
|
146
115
|
useSignal(HEALTH_CHECK_RUN_COMPLETED, ({ systemId: changedId }) => {
|
|
147
116
|
if (changedId === systemId) {
|
|
148
|
-
|
|
149
|
-
pagination.refetch();
|
|
117
|
+
pagination.silentRefetch();
|
|
150
118
|
}
|
|
151
119
|
});
|
|
152
120
|
|
|
@@ -156,6 +124,54 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
|
|
|
156
124
|
: `Window mode (${item.stateThresholds.windowSize} runs): Degraded at ${item.stateThresholds.degraded.minFailureCount}+ failures, Unhealthy at ${item.stateThresholds.unhealthy.minFailureCount}+ failures`
|
|
157
125
|
: "Using default thresholds";
|
|
158
126
|
|
|
127
|
+
// Render charts - charts handle data transformation internally
|
|
128
|
+
const renderCharts = () => {
|
|
129
|
+
if (chartLoading) {
|
|
130
|
+
return <LoadingSpinner />;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!chartContext) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check if we have data to show
|
|
138
|
+
const hasData =
|
|
139
|
+
chartContext.type === "raw"
|
|
140
|
+
? chartContext.runs.length > 0
|
|
141
|
+
: chartContext.buckets.length > 0;
|
|
142
|
+
|
|
143
|
+
if (!hasData) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div className="space-y-4">
|
|
149
|
+
{/* Status Timeline */}
|
|
150
|
+
<div>
|
|
151
|
+
<h4 className="text-sm font-medium mb-2">Status Timeline</h4>
|
|
152
|
+
<HealthCheckStatusTimeline context={chartContext} height={50} />
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{/* Latency Chart */}
|
|
156
|
+
<div>
|
|
157
|
+
<h4 className="text-sm font-medium mb-2">Response Latency</h4>
|
|
158
|
+
<HealthCheckLatencyChart
|
|
159
|
+
context={chartContext}
|
|
160
|
+
height={150}
|
|
161
|
+
showAverage
|
|
162
|
+
/>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
{/* Extension Slot for custom strategy-specific diagrams */}
|
|
166
|
+
<HealthCheckDiagram
|
|
167
|
+
context={chartContext}
|
|
168
|
+
isAggregated={isAggregated}
|
|
169
|
+
rawRetentionDays={retentionConfig.rawRetentionDays}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
|
|
159
175
|
return (
|
|
160
176
|
<div className="p-4 bg-muted/30 border-t space-y-4">
|
|
161
177
|
<div className="flex flex-wrap gap-4 text-sm">
|
|
@@ -179,54 +195,8 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
|
|
|
179
195
|
<DateRangeFilter value={dateRange} onChange={setDateRange} />
|
|
180
196
|
</div>
|
|
181
197
|
|
|
182
|
-
{/* Charts Section
|
|
183
|
-
{
|
|
184
|
-
<LoadingSpinner />
|
|
185
|
-
) : chartData.length > 0 ? (
|
|
186
|
-
<div className="space-y-4">
|
|
187
|
-
{/* Status Timeline */}
|
|
188
|
-
<div>
|
|
189
|
-
<h4 className="text-sm font-medium mb-2">Status Timeline</h4>
|
|
190
|
-
<HealthCheckStatusTimeline
|
|
191
|
-
type="raw"
|
|
192
|
-
data={chartData.map((r) => ({
|
|
193
|
-
timestamp: new Date(r.timestamp),
|
|
194
|
-
status: r.status,
|
|
195
|
-
}))}
|
|
196
|
-
height={50}
|
|
197
|
-
/>
|
|
198
|
-
</div>
|
|
199
|
-
|
|
200
|
-
{/* Latency Chart - only if any run has latency data */}
|
|
201
|
-
{chartData.some((r) => r.latencyMs !== undefined) && (
|
|
202
|
-
<div>
|
|
203
|
-
<h4 className="text-sm font-medium mb-2">Response Latency</h4>
|
|
204
|
-
<HealthCheckLatencyChart
|
|
205
|
-
type="raw"
|
|
206
|
-
data={chartData
|
|
207
|
-
.filter((r) => r.latencyMs !== undefined)
|
|
208
|
-
.map((r) => ({
|
|
209
|
-
timestamp: new Date(r.timestamp),
|
|
210
|
-
latencyMs: r.latencyMs!,
|
|
211
|
-
status: r.status,
|
|
212
|
-
}))}
|
|
213
|
-
height={150}
|
|
214
|
-
showAverage
|
|
215
|
-
/>
|
|
216
|
-
</div>
|
|
217
|
-
)}
|
|
218
|
-
|
|
219
|
-
{/* Extension Slot for custom strategy-specific diagrams */}
|
|
220
|
-
<HealthCheckDiagram
|
|
221
|
-
systemId={systemId}
|
|
222
|
-
configurationId={item.configurationId}
|
|
223
|
-
strategyId={item.strategyId}
|
|
224
|
-
dateRange={dateRange}
|
|
225
|
-
limit={1000}
|
|
226
|
-
offset={0}
|
|
227
|
-
/>
|
|
228
|
-
</div>
|
|
229
|
-
) : undefined}
|
|
198
|
+
{/* Charts Section */}
|
|
199
|
+
{renderCharts()}
|
|
230
200
|
|
|
231
201
|
{loading ? (
|
|
232
202
|
<LoadingSpinner />
|
|
@@ -246,7 +216,7 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
|
|
|
246
216
|
<TableCell>
|
|
247
217
|
<HealthBadge status={run.status} />
|
|
248
218
|
</TableCell>
|
|
249
|
-
<TableCell className="text-
|
|
219
|
+
<TableCell className="text-muted-foreground">
|
|
250
220
|
{formatDistanceToNow(new Date(run.timestamp), {
|
|
251
221
|
addSuffix: true,
|
|
252
222
|
})}
|
|
@@ -256,125 +226,178 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
|
|
|
256
226
|
</TableBody>
|
|
257
227
|
</Table>
|
|
258
228
|
</div>
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
)}
|
|
229
|
+
<Pagination
|
|
230
|
+
page={pagination.page}
|
|
231
|
+
totalPages={pagination.totalPages}
|
|
232
|
+
onPageChange={pagination.setPage}
|
|
233
|
+
total={pagination.total}
|
|
234
|
+
limit={pagination.limit}
|
|
235
|
+
onPageSizeChange={pagination.setLimit}
|
|
236
|
+
showPageSize
|
|
237
|
+
showTotal
|
|
238
|
+
/>
|
|
270
239
|
</>
|
|
271
240
|
) : (
|
|
272
|
-
<
|
|
241
|
+
<div className="text-center text-muted-foreground py-4">
|
|
242
|
+
No runs recorded yet
|
|
243
|
+
</div>
|
|
273
244
|
)}
|
|
274
245
|
</div>
|
|
275
246
|
);
|
|
276
247
|
};
|
|
277
248
|
|
|
278
|
-
export
|
|
279
|
-
const
|
|
280
|
-
const systemId = system?.id;
|
|
281
|
-
|
|
249
|
+
export function HealthCheckSystemOverview(props: SlotProps) {
|
|
250
|
+
const systemId = props.system.id;
|
|
282
251
|
const api = useApi(healthCheckApiRef);
|
|
283
|
-
const [overview, setOverview] = useState<HealthCheckOverviewItem[]>([]);
|
|
284
|
-
const [loading, setLoading] = useState(true);
|
|
285
|
-
const [expandedId, setExpandedId] = useState<string>();
|
|
286
252
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
253
|
+
// Fetch health check overview
|
|
254
|
+
const [overview, setOverview] = React.useState<HealthCheckOverviewItem[]>([]);
|
|
255
|
+
const [initialLoading, setInitialLoading] = React.useState(true);
|
|
256
|
+
const [expandedRow, setExpandedRow] = React.useState<string | undefined>();
|
|
257
|
+
|
|
258
|
+
const fetchOverview = React.useCallback(() => {
|
|
259
|
+
api.getSystemHealthOverview({ systemId }).then((data) => {
|
|
260
|
+
setOverview(
|
|
261
|
+
data.checks.map((check) => ({
|
|
262
|
+
configurationId: check.configurationId,
|
|
263
|
+
strategyId: check.strategyId,
|
|
264
|
+
name: check.configurationName,
|
|
265
|
+
state: check.status,
|
|
266
|
+
intervalSeconds: check.intervalSeconds,
|
|
267
|
+
lastRunAt: check.recentRuns[0]?.timestamp
|
|
268
|
+
? new Date(check.recentRuns[0].timestamp)
|
|
269
|
+
: undefined,
|
|
270
|
+
stateThresholds: check.stateThresholds,
|
|
271
|
+
recentStatusHistory: check.recentRuns.map((r) => r.status),
|
|
272
|
+
}))
|
|
273
|
+
);
|
|
274
|
+
setInitialLoading(false);
|
|
275
|
+
});
|
|
294
276
|
}, [api, systemId]);
|
|
295
277
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}, [refetch]);
|
|
278
|
+
React.useEffect(() => {
|
|
279
|
+
fetchOverview();
|
|
280
|
+
}, [fetchOverview]);
|
|
300
281
|
|
|
301
|
-
// Listen for realtime health check updates
|
|
282
|
+
// Listen for realtime health check updates - merge into existing state to avoid remounting expanded content
|
|
302
283
|
useSignal(HEALTH_CHECK_RUN_COMPLETED, ({ systemId: changedId }) => {
|
|
303
284
|
if (changedId === systemId) {
|
|
304
|
-
|
|
285
|
+
// Fetch fresh data but merge it into existing state to preserve object identity
|
|
286
|
+
// for unchanged items, preventing unnecessary re-renders of expanded content
|
|
287
|
+
api.getSystemHealthOverview({ systemId }).then((data) => {
|
|
288
|
+
setOverview((prev) => {
|
|
289
|
+
// Create a map of new items for quick lookup
|
|
290
|
+
const newItemsMap = new Map(
|
|
291
|
+
data.checks.map((item) => [item.configurationId, item])
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
// Update existing items in place, add new ones
|
|
295
|
+
const merged = prev.map((existing) => {
|
|
296
|
+
const updated = newItemsMap.get(existing.configurationId);
|
|
297
|
+
if (updated) {
|
|
298
|
+
newItemsMap.delete(existing.configurationId);
|
|
299
|
+
// Map API response to our internal format
|
|
300
|
+
const mappedItem: HealthCheckOverviewItem = {
|
|
301
|
+
configurationId: updated.configurationId,
|
|
302
|
+
strategyId: updated.strategyId,
|
|
303
|
+
name: updated.configurationName,
|
|
304
|
+
state: updated.status,
|
|
305
|
+
intervalSeconds: updated.intervalSeconds,
|
|
306
|
+
lastRunAt: updated.recentRuns[0]?.timestamp
|
|
307
|
+
? new Date(updated.recentRuns[0].timestamp)
|
|
308
|
+
: undefined,
|
|
309
|
+
stateThresholds: updated.stateThresholds,
|
|
310
|
+
recentStatusHistory: updated.recentRuns.map((r) => r.status),
|
|
311
|
+
};
|
|
312
|
+
// Return updated data but preserve reference if nothing changed
|
|
313
|
+
return JSON.stringify(existing) === JSON.stringify(mappedItem)
|
|
314
|
+
? existing
|
|
315
|
+
: mappedItem;
|
|
316
|
+
}
|
|
317
|
+
return existing;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// Add any new items that weren't in the previous list
|
|
321
|
+
for (const newItem of newItemsMap.values()) {
|
|
322
|
+
merged.push({
|
|
323
|
+
configurationId: newItem.configurationId,
|
|
324
|
+
strategyId: newItem.strategyId,
|
|
325
|
+
name: newItem.configurationName,
|
|
326
|
+
state: newItem.status,
|
|
327
|
+
intervalSeconds: newItem.intervalSeconds,
|
|
328
|
+
lastRunAt: newItem.recentRuns[0]?.timestamp
|
|
329
|
+
? new Date(newItem.recentRuns[0].timestamp)
|
|
330
|
+
: undefined,
|
|
331
|
+
stateThresholds: newItem.stateThresholds,
|
|
332
|
+
recentStatusHistory: newItem.recentRuns.map((r) => r.status),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Remove items that no longer exist
|
|
337
|
+
return merged.filter((item) =>
|
|
338
|
+
data.checks.some((c) => c.configurationId === item.configurationId)
|
|
339
|
+
);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
305
342
|
}
|
|
306
343
|
});
|
|
307
344
|
|
|
308
|
-
if (
|
|
345
|
+
if (initialLoading) {
|
|
346
|
+
return <LoadingSpinner />;
|
|
347
|
+
}
|
|
309
348
|
|
|
310
349
|
if (overview.length === 0) {
|
|
311
350
|
return (
|
|
312
|
-
<
|
|
313
|
-
No health checks
|
|
314
|
-
</
|
|
351
|
+
<div className="text-center text-muted-foreground py-4">
|
|
352
|
+
No health checks configured
|
|
353
|
+
</div>
|
|
315
354
|
);
|
|
316
355
|
}
|
|
317
356
|
|
|
318
357
|
return (
|
|
319
358
|
<div className="space-y-2">
|
|
320
359
|
{overview.map((item) => {
|
|
321
|
-
const isExpanded =
|
|
322
|
-
const lastRun = item.recentRuns[0];
|
|
360
|
+
const isExpanded = expandedRow === item.configurationId;
|
|
323
361
|
|
|
324
362
|
return (
|
|
325
|
-
<div
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
>
|
|
329
|
-
<div
|
|
330
|
-
className="flex items-center gap-4 p-3 cursor-pointer"
|
|
363
|
+
<div key={item.configurationId} className="rounded-md border bg-card">
|
|
364
|
+
<button
|
|
365
|
+
className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/50 transition-colors"
|
|
331
366
|
onClick={() =>
|
|
332
|
-
|
|
367
|
+
setExpandedRow(isExpanded ? undefined : item.configurationId)
|
|
333
368
|
}
|
|
334
369
|
>
|
|
335
|
-
<div className="
|
|
370
|
+
<div className="flex items-center gap-3">
|
|
336
371
|
{isExpanded ? (
|
|
337
|
-
<ChevronDown className="h-4 w-4" />
|
|
372
|
+
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
338
373
|
) : (
|
|
339
|
-
<ChevronRight className="h-4 w-4" />
|
|
374
|
+
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
340
375
|
)}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
<div className="flex items-center gap-2">
|
|
345
|
-
<span className="font-medium text-sm truncate">
|
|
346
|
-
{item.configurationName}
|
|
347
|
-
</span>
|
|
348
|
-
{!item.enabled && (
|
|
349
|
-
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
|
350
|
-
Disabled
|
|
351
|
-
</span>
|
|
352
|
-
)}
|
|
353
|
-
</div>
|
|
354
|
-
{lastRun && (
|
|
355
|
-
<span className="text-xs text-muted-foreground">
|
|
376
|
+
<div>
|
|
377
|
+
<div className="font-medium">{item.name}</div>
|
|
378
|
+
<div className="text-sm text-muted-foreground">
|
|
356
379
|
Last run:{" "}
|
|
357
|
-
{
|
|
358
|
-
addSuffix: true
|
|
359
|
-
|
|
360
|
-
</
|
|
380
|
+
{item.lastRunAt
|
|
381
|
+
? formatDistanceToNow(item.lastRunAt, { addSuffix: true })
|
|
382
|
+
: "never"}
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
<div className="flex items-center gap-4">
|
|
387
|
+
{item.recentStatusHistory.length > 0 && (
|
|
388
|
+
<HealthCheckSparkline
|
|
389
|
+
runs={item.recentStatusHistory.map((status) => ({
|
|
390
|
+
status,
|
|
391
|
+
}))}
|
|
392
|
+
/>
|
|
361
393
|
)}
|
|
394
|
+
<HealthBadge status={item.state} />
|
|
362
395
|
</div>
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
runs={item.recentRuns}
|
|
366
|
-
className="hidden sm:flex"
|
|
367
|
-
/>
|
|
368
|
-
|
|
369
|
-
<HealthBadge status={item.status} />
|
|
370
|
-
</div>
|
|
371
|
-
|
|
372
|
-
{isExpanded && systemId && (
|
|
373
|
-
<ExpandedDetails item={item} systemId={systemId} />
|
|
374
|
-
)}
|
|
396
|
+
</button>
|
|
397
|
+
{isExpanded && <ExpandedDetails item={item} systemId={systemId} />}
|
|
375
398
|
</div>
|
|
376
399
|
);
|
|
377
400
|
})}
|
|
378
401
|
</div>
|
|
379
402
|
);
|
|
380
|
-
}
|
|
403
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { useApi } from "@checkstack/frontend-api";
|
|
3
|
+
import { healthCheckApiRef } from "../api";
|
|
4
|
+
import { CollectorDto } from "@checkstack/healthcheck-common";
|
|
5
|
+
|
|
6
|
+
interface UseCollectorsResult {
|
|
7
|
+
collectors: CollectorDto[];
|
|
8
|
+
loading: boolean;
|
|
9
|
+
error: Error | undefined;
|
|
10
|
+
refetch: () => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Hook to fetch collectors for a given strategy.
|
|
15
|
+
* @param strategyId - The strategy ID to fetch collectors for
|
|
16
|
+
*/
|
|
17
|
+
export function useCollectors(strategyId: string): UseCollectorsResult {
|
|
18
|
+
const api = useApi(healthCheckApiRef);
|
|
19
|
+
const [collectors, setCollectors] = useState<CollectorDto[]>([]);
|
|
20
|
+
const [loading, setLoading] = useState(false);
|
|
21
|
+
const [error, setError] = useState<Error>();
|
|
22
|
+
|
|
23
|
+
const refetch = useCallback(async () => {
|
|
24
|
+
if (!strategyId) {
|
|
25
|
+
setCollectors([]);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
setLoading(true);
|
|
30
|
+
setError(undefined);
|
|
31
|
+
try {
|
|
32
|
+
const result = await api.getCollectors({ strategyId });
|
|
33
|
+
setCollectors(result);
|
|
34
|
+
} catch (error_) {
|
|
35
|
+
setError(
|
|
36
|
+
error_ instanceof Error
|
|
37
|
+
? error_
|
|
38
|
+
: new Error("Failed to fetch collectors")
|
|
39
|
+
);
|
|
40
|
+
} finally {
|
|
41
|
+
setLoading(false);
|
|
42
|
+
}
|
|
43
|
+
}, [api, strategyId]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
refetch();
|
|
47
|
+
}, [refetch]);
|
|
48
|
+
|
|
49
|
+
return { collectors, loading, error, refetch };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check if a collector is built-in for a given strategy.
|
|
54
|
+
* Built-in collectors are those registered by the same plugin as the strategy.
|
|
55
|
+
*/
|
|
56
|
+
export function isBuiltInCollector(
|
|
57
|
+
collectorId: string,
|
|
58
|
+
strategyId: string
|
|
59
|
+
): boolean {
|
|
60
|
+
// Collector ID format: ownerPluginId.collectorId
|
|
61
|
+
// Strategy ID typically equals its plugin ID
|
|
62
|
+
return collectorId.startsWith(`${strategyId}.`);
|
|
63
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { useEffect, useState, useMemo } from "react";
|
|
1
|
+
import { useEffect, useState, useMemo, useCallback } from "react";
|
|
2
2
|
import { useApi, permissionApiRef } from "@checkstack/frontend-api";
|
|
3
3
|
import { healthCheckApiRef } from "../api";
|
|
4
4
|
import {
|
|
5
5
|
permissions,
|
|
6
6
|
DEFAULT_RETENTION_CONFIG,
|
|
7
7
|
type RetentionConfig,
|
|
8
|
+
HEALTH_CHECK_RUN_COMPLETED,
|
|
8
9
|
} from "@checkstack/healthcheck-common";
|
|
10
|
+
import { useSignal } from "@checkstack/signal-frontend";
|
|
9
11
|
import type {
|
|
10
12
|
HealthCheckDiagramSlotContext,
|
|
11
13
|
TypedHealthCheckRun,
|
|
@@ -123,49 +125,66 @@ export function useHealthCheckData({
|
|
|
123
125
|
.finally(() => setRetentionLoading(false));
|
|
124
126
|
}, [api, systemId, configurationId]);
|
|
125
127
|
|
|
128
|
+
// Fetch raw data function - extracted for reuse by signal handler
|
|
129
|
+
const fetchRawData = useCallback(
|
|
130
|
+
(showLoading = true) => {
|
|
131
|
+
if (showLoading) {
|
|
132
|
+
setRawLoading(true);
|
|
133
|
+
}
|
|
134
|
+
api
|
|
135
|
+
.getDetailedHistory({
|
|
136
|
+
systemId,
|
|
137
|
+
configurationId,
|
|
138
|
+
startDate: dateRange.startDate,
|
|
139
|
+
// Don't pass endDate for live updates - backend defaults to 'now'
|
|
140
|
+
limit,
|
|
141
|
+
offset,
|
|
142
|
+
})
|
|
143
|
+
.then((response) => {
|
|
144
|
+
setRawRuns(
|
|
145
|
+
response.runs.map((r) => ({
|
|
146
|
+
id: r.id,
|
|
147
|
+
configurationId,
|
|
148
|
+
systemId,
|
|
149
|
+
status: r.status,
|
|
150
|
+
timestamp: r.timestamp,
|
|
151
|
+
latencyMs: r.latencyMs,
|
|
152
|
+
result: r.result,
|
|
153
|
+
}))
|
|
154
|
+
);
|
|
155
|
+
})
|
|
156
|
+
.finally(() => setRawLoading(false));
|
|
157
|
+
},
|
|
158
|
+
[api, systemId, configurationId, dateRange.startDate, limit, offset]
|
|
159
|
+
);
|
|
160
|
+
|
|
126
161
|
// Fetch raw data when in raw mode
|
|
127
162
|
useEffect(() => {
|
|
128
163
|
if (!hasPermission || permissionLoading || retentionLoading || isAggregated)
|
|
129
164
|
return;
|
|
130
|
-
|
|
131
|
-
setRawLoading(true);
|
|
132
|
-
api
|
|
133
|
-
.getDetailedHistory({
|
|
134
|
-
systemId,
|
|
135
|
-
configurationId,
|
|
136
|
-
startDate: dateRange.startDate,
|
|
137
|
-
endDate: dateRange.endDate,
|
|
138
|
-
limit,
|
|
139
|
-
offset,
|
|
140
|
-
})
|
|
141
|
-
.then((response) => {
|
|
142
|
-
setRawRuns(
|
|
143
|
-
response.runs.map((r) => ({
|
|
144
|
-
id: r.id,
|
|
145
|
-
configurationId,
|
|
146
|
-
systemId,
|
|
147
|
-
status: r.status,
|
|
148
|
-
timestamp: r.timestamp,
|
|
149
|
-
latencyMs: r.latencyMs,
|
|
150
|
-
result: r.result,
|
|
151
|
-
}))
|
|
152
|
-
);
|
|
153
|
-
})
|
|
154
|
-
.finally(() => setRawLoading(false));
|
|
165
|
+
fetchRawData(true);
|
|
155
166
|
}, [
|
|
156
|
-
|
|
157
|
-
systemId,
|
|
158
|
-
configurationId,
|
|
167
|
+
fetchRawData,
|
|
159
168
|
hasPermission,
|
|
160
169
|
permissionLoading,
|
|
161
170
|
retentionLoading,
|
|
162
171
|
isAggregated,
|
|
163
|
-
dateRange.startDate,
|
|
164
|
-
dateRange.endDate,
|
|
165
|
-
limit,
|
|
166
|
-
offset,
|
|
167
172
|
]);
|
|
168
173
|
|
|
174
|
+
// Listen for realtime health check updates to refresh data silently
|
|
175
|
+
useSignal(HEALTH_CHECK_RUN_COMPLETED, ({ systemId: changedId }) => {
|
|
176
|
+
// Only refresh if we're in raw mode (not aggregated) and have permission
|
|
177
|
+
if (
|
|
178
|
+
changedId === systemId &&
|
|
179
|
+
hasPermission &&
|
|
180
|
+
!permissionLoading &&
|
|
181
|
+
!retentionLoading &&
|
|
182
|
+
!isAggregated
|
|
183
|
+
) {
|
|
184
|
+
fetchRawData(false);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
169
188
|
// Fetch aggregated data when in aggregated mode
|
|
170
189
|
useEffect(() => {
|
|
171
190
|
if (
|