@checkstack/healthcheck-frontend 0.10.0 → 0.11.1
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 +69 -0
- package/package.json +9 -9
- package/src/auto-charts/AutoChartGrid.tsx +7 -4
- package/src/auto-charts/SingleRunChartGrid.tsx +359 -0
- package/src/auto-charts/index.ts +1 -0
- package/src/auto-charts/useStrategySchemas.ts +17 -0
- package/src/components/ExpandedResultView.tsx +17 -120
- package/src/components/HealthCheckSystemOverview.tsx +21 -36
- package/src/pages/HealthCheckHistoryDetailPage.tsx +19 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,74 @@
|
|
|
1
1
|
# @checkstack/healthcheck-frontend
|
|
2
2
|
|
|
3
|
+
## 0.11.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [0ebbe56]
|
|
8
|
+
- Updated dependencies [a340781]
|
|
9
|
+
- Updated dependencies [8d2660d]
|
|
10
|
+
- @checkstack/common@0.6.3
|
|
11
|
+
- @checkstack/ui@1.1.1
|
|
12
|
+
- @checkstack/dashboard-frontend@0.3.17
|
|
13
|
+
- @checkstack/auth-frontend@0.5.11
|
|
14
|
+
- @checkstack/catalog-common@1.2.8
|
|
15
|
+
- @checkstack/frontend-api@0.3.6
|
|
16
|
+
- @checkstack/healthcheck-common@0.8.3
|
|
17
|
+
- @checkstack/signal-frontend@0.0.13
|
|
18
|
+
|
|
19
|
+
## 0.11.0
|
|
20
|
+
|
|
21
|
+
### Minor Changes
|
|
22
|
+
|
|
23
|
+
- 84dd430: ## Single Run Auto-Charts
|
|
24
|
+
|
|
25
|
+
Added `SingleRunChartGrid` component to display auto-generated charts for individual health check runs when viewing run history details.
|
|
26
|
+
|
|
27
|
+
### Features
|
|
28
|
+
|
|
29
|
+
- Renders charts based on the strategy's `resultSchema` metadata (same as aggregated charts)
|
|
30
|
+
- Supports all chart types: gauge, counter, boolean, text, status
|
|
31
|
+
- Groups fields by collector instance with assertion status display
|
|
32
|
+
- Updated `useStrategySchemas` hook to also return `resultSchema` for single-run visualization
|
|
33
|
+
|
|
34
|
+
### Changes
|
|
35
|
+
|
|
36
|
+
- Simplified `ExpandedResultView` to show only basic run metadata (status, latency, connection)
|
|
37
|
+
- Collector results and detailed data now displayed via `SingleRunChartGrid`
|
|
38
|
+
|
|
39
|
+
### Patch Changes
|
|
40
|
+
|
|
41
|
+
- c842373: ## Animated Numbers & Availability Stats Live Updates
|
|
42
|
+
|
|
43
|
+
### Features
|
|
44
|
+
|
|
45
|
+
- **AnimatedNumber component** (`@checkstack/ui`): New reusable component that displays numbers with a smooth "rolling" animation when values change. Uses `requestAnimationFrame` with eased interpolation for a polished effect.
|
|
46
|
+
- **useAnimatedNumber hook** (`@checkstack/ui`): Underlying hook for the animation logic, can be used directly for custom implementations.
|
|
47
|
+
- **Live availability updates**: Availability stats (31-day and 365-day) now automatically refresh when new health check runs are received via signals.
|
|
48
|
+
|
|
49
|
+
### Usage
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
import { AnimatedNumber } from "@checkstack/ui";
|
|
53
|
+
|
|
54
|
+
<AnimatedNumber
|
|
55
|
+
value={99.95}
|
|
56
|
+
suffix="%"
|
|
57
|
+
decimals={2}
|
|
58
|
+
duration={500}
|
|
59
|
+
className="text-2xl font-bold text-green-500"
|
|
60
|
+
/>;
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- c842373: ## Fix Counter Chart Multiplier Display
|
|
64
|
+
|
|
65
|
+
Hide redundant "(1×)" multiplier suffix for single-value counters in auto-charts. For aggregated counter values like "Errors", the displayed value itself represents the count, so showing "(1×)" adds no information and is confusing.
|
|
66
|
+
|
|
67
|
+
- Updated dependencies [c842373]
|
|
68
|
+
- @checkstack/ui@1.1.0
|
|
69
|
+
- @checkstack/auth-frontend@0.5.10
|
|
70
|
+
- @checkstack/dashboard-frontend@0.3.16
|
|
71
|
+
|
|
3
72
|
## 0.10.0
|
|
4
73
|
|
|
5
74
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/healthcheck-frontend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.tsx",
|
|
6
6
|
"scripts": {
|
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
"lint:code": "eslint . --max-warnings 0"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@checkstack/auth-frontend": "0.5.
|
|
13
|
-
"@checkstack/catalog-common": "1.2.
|
|
14
|
-
"@checkstack/common": "0.6.
|
|
15
|
-
"@checkstack/dashboard-frontend": "0.3.
|
|
16
|
-
"@checkstack/frontend-api": "0.3.
|
|
17
|
-
"@checkstack/healthcheck-common": "0.8.
|
|
18
|
-
"@checkstack/signal-frontend": "0.0.
|
|
19
|
-
"@checkstack/ui": "
|
|
12
|
+
"@checkstack/auth-frontend": "0.5.10",
|
|
13
|
+
"@checkstack/catalog-common": "1.2.7",
|
|
14
|
+
"@checkstack/common": "0.6.2",
|
|
15
|
+
"@checkstack/dashboard-frontend": "0.3.16",
|
|
16
|
+
"@checkstack/frontend-api": "0.3.5",
|
|
17
|
+
"@checkstack/healthcheck-common": "0.8.2",
|
|
18
|
+
"@checkstack/signal-frontend": "0.0.12",
|
|
19
|
+
"@checkstack/ui": "1.1.0",
|
|
20
20
|
"ajv": "^8.17.1",
|
|
21
21
|
"ajv-formats": "^3.0.1",
|
|
22
22
|
"date-fns": "^4.1.0",
|
|
@@ -465,15 +465,18 @@ function CounterRenderer({ field, context }: ChartRendererProps) {
|
|
|
465
465
|
// Sort by count (descending) then by value
|
|
466
466
|
entries.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
467
467
|
|
|
468
|
-
// If there's only one unique value, show it prominently
|
|
468
|
+
// If there's only one unique value, show it prominently
|
|
469
|
+
// Only show count multiplier if > 1 (avoids confusing "(1×)" for aggregated counters)
|
|
469
470
|
if (entries.length === 1) {
|
|
470
471
|
const [value, count] = entries[0];
|
|
471
472
|
return (
|
|
472
473
|
<div className="text-2xl font-bold">
|
|
473
474
|
{value}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
475
|
+
{count > 1 && (
|
|
476
|
+
<span className="text-sm font-normal text-muted-foreground ml-2">
|
|
477
|
+
({count}×)
|
|
478
|
+
</span>
|
|
479
|
+
)}
|
|
477
480
|
</div>
|
|
478
481
|
);
|
|
479
482
|
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SingleRunChartGrid - Renders auto-generated charts for a single health check run.
|
|
3
|
+
*
|
|
4
|
+
* Unlike AutoChartGrid which shows time series data, this component displays
|
|
5
|
+
* static values from a single run's result/metadata.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ChartField } from "./schema-parser";
|
|
9
|
+
import { extractChartFields, getFieldValue } from "./schema-parser";
|
|
10
|
+
import { useStrategySchemas } from "./useStrategySchemas";
|
|
11
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@checkstack/ui";
|
|
12
|
+
import { RadialBarChart, RadialBar, ResponsiveContainer } from "recharts";
|
|
13
|
+
|
|
14
|
+
interface SingleRunChartGridProps {
|
|
15
|
+
/** Strategy ID (qualified, e.g., "healthcheck-http-backend.http") */
|
|
16
|
+
strategyId: string;
|
|
17
|
+
/** The run's result data containing metadata */
|
|
18
|
+
result: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Main component that renders a grid of charts for a single run.
|
|
23
|
+
*/
|
|
24
|
+
export function SingleRunChartGrid({
|
|
25
|
+
strategyId,
|
|
26
|
+
result,
|
|
27
|
+
}: SingleRunChartGridProps) {
|
|
28
|
+
const { schemas, loading } = useStrategySchemas(strategyId);
|
|
29
|
+
|
|
30
|
+
if (loading) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!schemas) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Use result schema for per-run data
|
|
39
|
+
const schema = schemas.resultSchema;
|
|
40
|
+
|
|
41
|
+
const schemaFields = extractChartFields(schema);
|
|
42
|
+
if (schemaFields.length === 0) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Get the metadata from the result
|
|
47
|
+
const metadata = result.metadata as Record<string, unknown> | undefined;
|
|
48
|
+
if (!metadata) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Discover collector instances from result data
|
|
53
|
+
const collectors = metadata.collectors as
|
|
54
|
+
| Record<string, Record<string, unknown>>
|
|
55
|
+
| undefined;
|
|
56
|
+
const collectorEntries = collectors ? Object.entries(collectors) : [];
|
|
57
|
+
|
|
58
|
+
// Separate strategy-level fields from collector fields
|
|
59
|
+
const strategyFields = schemaFields.filter((f) => !f.collectorId);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="space-y-6">
|
|
63
|
+
{/* Strategy-level fields */}
|
|
64
|
+
{strategyFields.length > 0 && (
|
|
65
|
+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
66
|
+
{strategyFields.map((field) => (
|
|
67
|
+
<SingleValueCard
|
|
68
|
+
key={field.name}
|
|
69
|
+
field={field}
|
|
70
|
+
value={getFieldValue(metadata, field.name)}
|
|
71
|
+
/>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
{/* Collector groups */}
|
|
77
|
+
{collectorEntries
|
|
78
|
+
.filter(([, collectorData]) => {
|
|
79
|
+
const collectorId = collectorData._collectorId as string | undefined;
|
|
80
|
+
if (!collectorId) return false;
|
|
81
|
+
const collectorFields = schemaFields.filter(
|
|
82
|
+
(f) => f.collectorId === collectorId,
|
|
83
|
+
);
|
|
84
|
+
return collectorFields.length > 0;
|
|
85
|
+
})
|
|
86
|
+
.map(([instanceId, collectorData]) => {
|
|
87
|
+
const collectorId = collectorData._collectorId as string;
|
|
88
|
+
const collectorFields = schemaFields.filter(
|
|
89
|
+
(f) => f.collectorId === collectorId,
|
|
90
|
+
);
|
|
91
|
+
return (
|
|
92
|
+
<CollectorSection
|
|
93
|
+
key={instanceId}
|
|
94
|
+
instanceId={instanceId}
|
|
95
|
+
collectorId={collectorId}
|
|
96
|
+
fields={collectorFields}
|
|
97
|
+
data={collectorData}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
})}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface CollectorSectionProps {
|
|
106
|
+
instanceId: string;
|
|
107
|
+
collectorId: string;
|
|
108
|
+
fields: ChartField[];
|
|
109
|
+
data: Record<string, unknown>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Section for a single collector instance.
|
|
114
|
+
*/
|
|
115
|
+
function CollectorSection({
|
|
116
|
+
instanceId,
|
|
117
|
+
collectorId,
|
|
118
|
+
fields,
|
|
119
|
+
data,
|
|
120
|
+
}: CollectorSectionProps) {
|
|
121
|
+
const displayName = collectorId.split(".").pop() || collectorId;
|
|
122
|
+
const assertionFailed = data._assertionFailed as string | undefined;
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className="space-y-4">
|
|
126
|
+
<div className="flex items-center gap-2">
|
|
127
|
+
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
|
128
|
+
{displayName}
|
|
129
|
+
</h4>
|
|
130
|
+
<span className="text-xs text-muted-foreground">
|
|
131
|
+
({instanceId.slice(0, 8)})
|
|
132
|
+
</span>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{/* Assertion status if present */}
|
|
136
|
+
{assertionFailed && (
|
|
137
|
+
<Card className="border-red-200 dark:border-red-900">
|
|
138
|
+
<CardHeader className="pb-2">
|
|
139
|
+
<CardTitle className="text-sm font-medium text-red-600">
|
|
140
|
+
Assertion Failed
|
|
141
|
+
</CardTitle>
|
|
142
|
+
</CardHeader>
|
|
143
|
+
<CardContent>
|
|
144
|
+
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-950 px-2 py-1 rounded">
|
|
145
|
+
{assertionFailed}
|
|
146
|
+
</div>
|
|
147
|
+
</CardContent>
|
|
148
|
+
</Card>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
152
|
+
{fields.map((field) => (
|
|
153
|
+
<SingleValueCard
|
|
154
|
+
key={field.name}
|
|
155
|
+
field={field}
|
|
156
|
+
value={getFieldValue(data, field.name)}
|
|
157
|
+
/>
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface SingleValueCardProps {
|
|
165
|
+
field: ChartField;
|
|
166
|
+
value: unknown;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Card displaying a single value based on its chart type.
|
|
171
|
+
*/
|
|
172
|
+
function SingleValueCard({ field, value }: SingleValueCardProps) {
|
|
173
|
+
return (
|
|
174
|
+
<Card>
|
|
175
|
+
<CardHeader className="pb-2">
|
|
176
|
+
<CardTitle className="text-sm font-medium">{field.label}</CardTitle>
|
|
177
|
+
</CardHeader>
|
|
178
|
+
<CardContent>
|
|
179
|
+
<SingleValueRenderer field={field} value={value} />
|
|
180
|
+
</CardContent>
|
|
181
|
+
</Card>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface SingleValueRendererProps {
|
|
186
|
+
field: ChartField;
|
|
187
|
+
value: unknown;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Dispatches to appropriate renderer based on chart type.
|
|
192
|
+
*/
|
|
193
|
+
function SingleValueRenderer({ field, value }: SingleValueRendererProps) {
|
|
194
|
+
switch (field.chartType) {
|
|
195
|
+
case "line":
|
|
196
|
+
case "counter": {
|
|
197
|
+
return <NumberRenderer value={value} unit={field.unit} />;
|
|
198
|
+
}
|
|
199
|
+
case "gauge": {
|
|
200
|
+
return <GaugeRenderer value={value} unit={field.unit} />;
|
|
201
|
+
}
|
|
202
|
+
case "boolean": {
|
|
203
|
+
return <BooleanRenderer value={value} />;
|
|
204
|
+
}
|
|
205
|
+
case "text": {
|
|
206
|
+
return <TextRenderer value={value} />;
|
|
207
|
+
}
|
|
208
|
+
case "status": {
|
|
209
|
+
return <StatusRenderer value={value} />;
|
|
210
|
+
}
|
|
211
|
+
case "bar":
|
|
212
|
+
case "pie": {
|
|
213
|
+
// For bar/pie, just show the value since we can't do distributions with a single point
|
|
214
|
+
return <TextRenderer value={value} />;
|
|
215
|
+
}
|
|
216
|
+
default: {
|
|
217
|
+
return <TextRenderer value={value} />;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Renders a numeric value with optional unit.
|
|
224
|
+
*/
|
|
225
|
+
function NumberRenderer({ value, unit }: { value: unknown; unit?: string }) {
|
|
226
|
+
if (value === undefined || value === null) {
|
|
227
|
+
return <div className="text-muted-foreground">—</div>;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const numValue = typeof value === "number" ? value : Number(value);
|
|
231
|
+
if (Number.isNaN(numValue)) {
|
|
232
|
+
return <div className="text-muted-foreground">{String(value)}</div>;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const formatted = Number.isInteger(numValue)
|
|
236
|
+
? String(numValue)
|
|
237
|
+
: numValue.toFixed(2);
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<div className="text-2xl font-bold">
|
|
241
|
+
{formatted}
|
|
242
|
+
{unit && (
|
|
243
|
+
<span className="text-sm font-normal text-muted-foreground ml-1">
|
|
244
|
+
{unit}
|
|
245
|
+
</span>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Renders a percentage gauge visualization.
|
|
253
|
+
*/
|
|
254
|
+
function GaugeRenderer({ value, unit }: { value: unknown; unit?: string }) {
|
|
255
|
+
if (value === undefined || value === null) {
|
|
256
|
+
return <div className="text-muted-foreground">—</div>;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const numValue = typeof value === "number" ? value : Number(value);
|
|
260
|
+
if (Number.isNaN(numValue)) {
|
|
261
|
+
return <div className="text-muted-foreground">{String(value)}</div>;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const clampedValue = Math.min(100, Math.max(0, numValue));
|
|
265
|
+
const displayUnit = unit ?? "%";
|
|
266
|
+
|
|
267
|
+
// Determine color based on value
|
|
268
|
+
const fillColor =
|
|
269
|
+
clampedValue >= 90
|
|
270
|
+
? "hsl(var(--success))"
|
|
271
|
+
: clampedValue >= 70
|
|
272
|
+
? "hsl(var(--warning))"
|
|
273
|
+
: "hsl(var(--destructive))";
|
|
274
|
+
|
|
275
|
+
const data = [{ name: "value", value: clampedValue, fill: fillColor }];
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<div className="flex items-center gap-3">
|
|
279
|
+
<ResponsiveContainer width={80} height={80}>
|
|
280
|
+
<RadialBarChart
|
|
281
|
+
cx="50%"
|
|
282
|
+
cy="50%"
|
|
283
|
+
innerRadius="60%"
|
|
284
|
+
outerRadius="100%"
|
|
285
|
+
barSize={8}
|
|
286
|
+
data={data}
|
|
287
|
+
startAngle={90}
|
|
288
|
+
endAngle={-270}
|
|
289
|
+
>
|
|
290
|
+
<RadialBar
|
|
291
|
+
dataKey="value"
|
|
292
|
+
cornerRadius={4}
|
|
293
|
+
background={{ fill: "hsl(var(--muted))" }}
|
|
294
|
+
/>
|
|
295
|
+
</RadialBarChart>
|
|
296
|
+
</ResponsiveContainer>
|
|
297
|
+
<div className="text-2xl font-bold" style={{ color: fillColor }}>
|
|
298
|
+
{clampedValue.toFixed(1)}
|
|
299
|
+
{displayUnit}
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Renders a boolean indicator.
|
|
307
|
+
*/
|
|
308
|
+
function BooleanRenderer({ value }: { value: unknown }) {
|
|
309
|
+
if (value === undefined || value === null) {
|
|
310
|
+
return <div className="text-muted-foreground">—</div>;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const boolValue = Boolean(value);
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<div className="flex items-center gap-2">
|
|
317
|
+
<div
|
|
318
|
+
className={`w-3 h-3 rounded-full ${
|
|
319
|
+
boolValue ? "bg-green-500" : "bg-red-500"
|
|
320
|
+
}`}
|
|
321
|
+
/>
|
|
322
|
+
<span className={boolValue ? "text-green-600" : "text-red-600"}>
|
|
323
|
+
{boolValue ? "Yes" : "No"}
|
|
324
|
+
</span>
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Renders a text value.
|
|
331
|
+
*/
|
|
332
|
+
function TextRenderer({ value }: { value: unknown }) {
|
|
333
|
+
if (value === undefined || value === null || value === "") {
|
|
334
|
+
return <div className="text-muted-foreground">—</div>;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const strValue = String(value);
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<div className="text-sm font-mono truncate" title={strValue}>
|
|
341
|
+
{strValue}
|
|
342
|
+
</div>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Renders an error/status badge.
|
|
348
|
+
*/
|
|
349
|
+
function StatusRenderer({ value }: { value: unknown }) {
|
|
350
|
+
if (value === undefined || value === null || value === "") {
|
|
351
|
+
return <div className="text-sm text-muted-foreground">No errors</div>;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-950 px-2 py-1 rounded truncate">
|
|
356
|
+
{String(value)}
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
}
|
package/src/auto-charts/index.ts
CHANGED
|
@@ -8,5 +8,6 @@
|
|
|
8
8
|
export { extractChartFields, getFieldValue } from "./schema-parser";
|
|
9
9
|
export type { ChartField } from "./schema-parser";
|
|
10
10
|
export { AutoChartGrid } from "./AutoChartGrid";
|
|
11
|
+
export { SingleRunChartGrid } from "./SingleRunChartGrid";
|
|
11
12
|
export { useStrategySchemas } from "./useStrategySchemas";
|
|
12
13
|
export { autoChartExtension } from "./extension";
|
|
@@ -11,6 +11,7 @@ import { usePluginClient } from "@checkstack/frontend-api";
|
|
|
11
11
|
import { HealthCheckApi } from "../api";
|
|
12
12
|
|
|
13
13
|
interface StrategySchemas {
|
|
14
|
+
resultSchema: Record<string, unknown> | undefined;
|
|
14
15
|
aggregatedResultSchema: Record<string, unknown> | undefined;
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -65,7 +66,23 @@ export function useStrategySchemas(strategyId: string): {
|
|
|
65
66
|
collectorAggregatedProperties,
|
|
66
67
|
);
|
|
67
68
|
|
|
69
|
+
// Build collector result schemas for nesting under resultSchema.properties.collectors
|
|
70
|
+
const collectorResultProperties: Record<string, unknown> = {};
|
|
71
|
+
|
|
72
|
+
for (const collector of collectors) {
|
|
73
|
+
if (collector.resultSchema) {
|
|
74
|
+
collectorResultProperties[collector.id] = collector.resultSchema;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Merge collector result schemas into strategy result schema
|
|
79
|
+
const mergedResultSchema = mergeCollectorSchemas(
|
|
80
|
+
strategy.resultSchema as Record<string, unknown> | undefined,
|
|
81
|
+
collectorResultProperties,
|
|
82
|
+
);
|
|
83
|
+
|
|
68
84
|
setSchemas({
|
|
85
|
+
resultSchema: mergedResultSchema,
|
|
69
86
|
aggregatedResultSchema: mergedAggregatedSchema,
|
|
70
87
|
});
|
|
71
88
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ExpandedResultView - Displays health check result
|
|
2
|
+
* ExpandedResultView - Displays basic health check result metadata.
|
|
3
|
+
*
|
|
4
|
+
* Shows status, latency, and connection time. Collector results and
|
|
5
|
+
* detailed visualizations are handled by SingleRunChartGrid.
|
|
3
6
|
*/
|
|
4
7
|
|
|
5
8
|
interface ExpandedResultViewProps {
|
|
@@ -7,134 +10,28 @@ interface ExpandedResultViewProps {
|
|
|
7
10
|
}
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
|
-
* Displays
|
|
11
|
-
* Shows collector results as cards with key-value pairs.
|
|
13
|
+
* Displays basic run metadata (status, latency, connection time).
|
|
12
14
|
*/
|
|
13
15
|
export function ExpandedResultView({ result }: ExpandedResultViewProps) {
|
|
14
16
|
const metadata = result.metadata as Record<string, unknown> | undefined;
|
|
15
|
-
const rawCollectors = metadata?.collectors;
|
|
16
|
-
|
|
17
|
-
// Type guard for collectors object
|
|
18
|
-
const collectors: Record<string, Record<string, unknown>> | undefined =
|
|
19
|
-
rawCollectors &&
|
|
20
|
-
typeof rawCollectors === "object" &&
|
|
21
|
-
!Array.isArray(rawCollectors)
|
|
22
|
-
? (rawCollectors as Record<string, Record<string, unknown>>)
|
|
23
|
-
: undefined;
|
|
24
|
-
|
|
25
|
-
// Check if we have collectors to display
|
|
26
|
-
const collectorEntries = collectors ? Object.entries(collectors) : [];
|
|
27
|
-
|
|
28
|
-
// Extract connection time as typed value
|
|
29
17
|
const connectionTimeMs = metadata?.connectionTimeMs as number | undefined;
|
|
30
18
|
|
|
31
19
|
return (
|
|
32
|
-
<div className="
|
|
33
|
-
<div
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
<span className="font-medium">{String(result.status)}</span>
|
|
37
|
-
</div>
|
|
38
|
-
<div>
|
|
39
|
-
<span className="text-muted-foreground">Latency: </span>
|
|
40
|
-
<span className="font-medium">{String(result.latencyMs)}ms</span>
|
|
41
|
-
</div>
|
|
42
|
-
{connectionTimeMs !== undefined && (
|
|
43
|
-
<div>
|
|
44
|
-
<span className="text-muted-foreground">Connection: </span>
|
|
45
|
-
<span className="font-medium">{connectionTimeMs}ms</span>
|
|
46
|
-
</div>
|
|
47
|
-
)}
|
|
20
|
+
<div className="flex flex-wrap gap-4 text-sm">
|
|
21
|
+
<div>
|
|
22
|
+
<span className="text-muted-foreground">Status: </span>
|
|
23
|
+
<span className="font-medium">{String(result.status)}</span>
|
|
48
24
|
</div>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
collectorId={collectorId}
|
|
58
|
-
result={collectorResult}
|
|
59
|
-
/>
|
|
60
|
-
))}
|
|
61
|
-
</div>
|
|
25
|
+
<div>
|
|
26
|
+
<span className="text-muted-foreground">Latency: </span>
|
|
27
|
+
<span className="font-medium">{String(result.latencyMs)}ms</span>
|
|
28
|
+
</div>
|
|
29
|
+
{connectionTimeMs !== undefined && (
|
|
30
|
+
<div>
|
|
31
|
+
<span className="text-muted-foreground">Connection: </span>
|
|
32
|
+
<span className="font-medium">{connectionTimeMs}ms</span>
|
|
62
33
|
</div>
|
|
63
34
|
)}
|
|
64
|
-
|
|
65
|
-
{result.message ? (
|
|
66
|
-
<div className="text-sm text-muted-foreground">
|
|
67
|
-
{String(result.message)}
|
|
68
|
-
</div>
|
|
69
|
-
) : undefined}
|
|
70
|
-
</div>
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
interface CollectorResultCardProps {
|
|
75
|
-
collectorId: string;
|
|
76
|
-
result: Record<string, unknown>;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Card displaying a single collector's result values.
|
|
81
|
-
*/
|
|
82
|
-
function CollectorResultCard({
|
|
83
|
-
collectorId,
|
|
84
|
-
result,
|
|
85
|
-
}: CollectorResultCardProps) {
|
|
86
|
-
if (!result || typeof result !== "object") {
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Filter out null/undefined values
|
|
91
|
-
const entries = Object.entries(result).filter(
|
|
92
|
-
([, value]) => value !== null && value !== undefined,
|
|
93
|
-
);
|
|
94
|
-
|
|
95
|
-
return (
|
|
96
|
-
<div className="rounded-md border bg-card p-3 space-y-2">
|
|
97
|
-
<h5 className="text-sm font-medium text-primary">{collectorId}</h5>
|
|
98
|
-
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
|
99
|
-
{entries.map(([key, value]) => (
|
|
100
|
-
<div key={key} className="contents">
|
|
101
|
-
<span className="text-muted-foreground truncate">
|
|
102
|
-
{formatKey(key)}
|
|
103
|
-
</span>
|
|
104
|
-
<span className="font-mono text-xs truncate" title={String(value)}>
|
|
105
|
-
{formatValue(value)}
|
|
106
|
-
</span>
|
|
107
|
-
</div>
|
|
108
|
-
))}
|
|
109
|
-
</div>
|
|
110
35
|
</div>
|
|
111
36
|
);
|
|
112
37
|
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Format a camelCase key to a readable label.
|
|
116
|
-
*/
|
|
117
|
-
function formatKey(key: string): string {
|
|
118
|
-
return key
|
|
119
|
-
.replaceAll(/([a-z])([A-Z])/g, "$1 $2")
|
|
120
|
-
.replace(/^./, (c) => c.toUpperCase());
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Format a value for display.
|
|
125
|
-
*/
|
|
126
|
-
function formatValue(value: unknown): string {
|
|
127
|
-
if (value === null || value === undefined) return "—";
|
|
128
|
-
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
129
|
-
if (typeof value === "number") {
|
|
130
|
-
return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
|
131
|
-
}
|
|
132
|
-
if (Array.isArray(value)) {
|
|
133
|
-
return value.length > 3
|
|
134
|
-
? `[${value.slice(0, 3).join(", ")}…]`
|
|
135
|
-
: `[${value.join(", ")}]`;
|
|
136
|
-
}
|
|
137
|
-
if (typeof value === "object") return JSON.stringify(value);
|
|
138
|
-
const str = String(value);
|
|
139
|
-
return str.length > 50 ? `${str.slice(0, 47)}…` : str;
|
|
140
|
-
}
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
CardContent,
|
|
37
37
|
CardHeader,
|
|
38
38
|
CardTitle,
|
|
39
|
+
AnimatedNumber,
|
|
39
40
|
} from "@checkstack/ui";
|
|
40
41
|
import { formatDistanceToNow } from "date-fns";
|
|
41
42
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
@@ -70,28 +71,21 @@ interface ExpandedRowProps {
|
|
|
70
71
|
systemId: string;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
// Helper to
|
|
74
|
-
const
|
|
74
|
+
// Helper to get color class for availability percentage
|
|
75
|
+
const getAvailabilityColorClass = (
|
|
75
76
|
value: number | null,
|
|
76
77
|
totalRuns: number,
|
|
77
|
-
):
|
|
78
|
+
): string => {
|
|
78
79
|
if (value === null || totalRuns === 0) {
|
|
79
|
-
return
|
|
80
|
+
return "text-muted-foreground";
|
|
80
81
|
}
|
|
81
|
-
const formatted = value.toFixed(2) + "%";
|
|
82
82
|
if (value >= 99.9) {
|
|
83
|
-
return
|
|
84
|
-
text: formatted,
|
|
85
|
-
className: "text-green-600 dark:text-green-400",
|
|
86
|
-
};
|
|
83
|
+
return "text-green-600 dark:text-green-400";
|
|
87
84
|
}
|
|
88
85
|
if (value >= 99) {
|
|
89
|
-
return
|
|
90
|
-
text: formatted,
|
|
91
|
-
className: "text-yellow-600 dark:text-yellow-400",
|
|
92
|
-
};
|
|
86
|
+
return "text-yellow-600 dark:text-yellow-400";
|
|
93
87
|
}
|
|
94
|
-
return
|
|
88
|
+
return "text-red-600 dark:text-red-400";
|
|
95
89
|
};
|
|
96
90
|
|
|
97
91
|
const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
|
|
@@ -199,11 +193,12 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
|
|
|
199
193
|
}
|
|
200
194
|
const runs = displayRuns;
|
|
201
195
|
|
|
202
|
-
// Listen for realtime health check updates to refresh history table
|
|
196
|
+
// Listen for realtime health check updates to refresh history table and availability stats
|
|
203
197
|
// Charts are refreshed automatically by useHealthCheckData
|
|
204
198
|
useSignal(HEALTH_CHECK_RUN_COMPLETED, ({ systemId: changedId }) => {
|
|
205
199
|
if (changedId === systemId) {
|
|
206
200
|
void refetch();
|
|
201
|
+
void refetchAvailability();
|
|
207
202
|
}
|
|
208
203
|
});
|
|
209
204
|
|
|
@@ -214,7 +209,7 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
|
|
|
214
209
|
: "Using default thresholds";
|
|
215
210
|
|
|
216
211
|
// Fetch availability stats
|
|
217
|
-
const { data: availabilityData } =
|
|
212
|
+
const { data: availabilityData, refetch: refetchAvailability } =
|
|
218
213
|
healthCheckClient.getAvailabilityStats.useQuery({
|
|
219
214
|
systemId,
|
|
220
215
|
configurationId: item.configurationId,
|
|
@@ -302,16 +297,11 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
|
|
|
302
297
|
31-Day Availability
|
|
303
298
|
</span>
|
|
304
299
|
<div className="flex items-baseline gap-2">
|
|
305
|
-
<
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
{
|
|
309
|
-
|
|
310
|
-
availabilityData.availability31Days,
|
|
311
|
-
availabilityData.totalRuns31Days,
|
|
312
|
-
).text
|
|
313
|
-
}
|
|
314
|
-
</span>
|
|
300
|
+
<AnimatedNumber
|
|
301
|
+
value={availabilityData.availability31Days ?? undefined}
|
|
302
|
+
suffix="%"
|
|
303
|
+
className={`text-2xl font-bold ${getAvailabilityColorClass(availabilityData.availability31Days, availabilityData.totalRuns31Days)}`}
|
|
304
|
+
/>
|
|
315
305
|
{availabilityData.totalRuns31Days > 0 && (
|
|
316
306
|
<span className="text-sm text-muted-foreground">
|
|
317
307
|
({availabilityData.totalRuns31Days.toLocaleString()} runs)
|
|
@@ -324,16 +314,11 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
|
|
|
324
314
|
365-Day Availability
|
|
325
315
|
</span>
|
|
326
316
|
<div className="flex items-baseline gap-2">
|
|
327
|
-
<
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
{
|
|
331
|
-
|
|
332
|
-
availabilityData.availability365Days,
|
|
333
|
-
availabilityData.totalRuns365Days,
|
|
334
|
-
).text
|
|
335
|
-
}
|
|
336
|
-
</span>
|
|
317
|
+
<AnimatedNumber
|
|
318
|
+
value={availabilityData.availability365Days ?? undefined}
|
|
319
|
+
suffix="%"
|
|
320
|
+
className={`text-2xl font-bold ${getAvailabilityColorClass(availabilityData.availability365Days, availabilityData.totalRuns365Days)}`}
|
|
321
|
+
/>
|
|
337
322
|
{availabilityData.totalRuns365Days > 0 && (
|
|
338
323
|
<span className="text-sm text-muted-foreground">
|
|
339
324
|
({availabilityData.totalRuns365Days.toLocaleString()} runs)
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type HealthCheckRunDetailed,
|
|
34
34
|
} from "../components/HealthCheckRunsTable";
|
|
35
35
|
import { ExpandedResultView } from "../components/ExpandedResultView";
|
|
36
|
+
import { SingleRunChartGrid } from "../auto-charts";
|
|
36
37
|
|
|
37
38
|
const HealthCheckHistoryDetailPageContent = () => {
|
|
38
39
|
const { systemId, configurationId, runId } = useParams<{
|
|
@@ -63,6 +64,17 @@ const HealthCheckHistoryDetailPageContent = () => {
|
|
|
63
64
|
},
|
|
64
65
|
);
|
|
65
66
|
|
|
67
|
+
// Fetch configurations to get strategyId
|
|
68
|
+
const { data: configurations } = healthCheckClient.getConfigurations.useQuery(
|
|
69
|
+
{},
|
|
70
|
+
{
|
|
71
|
+
enabled: !!configurationId,
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
const configuration = configurations?.configurations.find(
|
|
75
|
+
(c) => c.id === configurationId,
|
|
76
|
+
);
|
|
77
|
+
|
|
66
78
|
// Fetch data with useQuery - newest first for table display
|
|
67
79
|
const { data, isLoading } = healthCheckClient.getDetailedHistory.useQuery({
|
|
68
80
|
systemId,
|
|
@@ -127,8 +139,14 @@ const HealthCheckHistoryDetailPageContent = () => {
|
|
|
127
139
|
{format(new Date(specificRun.timestamp), "PPpp")}
|
|
128
140
|
</p>
|
|
129
141
|
</CardHeader>
|
|
130
|
-
<CardContent>
|
|
142
|
+
<CardContent className="space-y-4">
|
|
131
143
|
<ExpandedResultView result={specificRun.result} />
|
|
144
|
+
{configuration?.strategyId && (
|
|
145
|
+
<SingleRunChartGrid
|
|
146
|
+
strategyId={configuration.strategyId}
|
|
147
|
+
result={specificRun.result}
|
|
148
|
+
/>
|
|
149
|
+
)}
|
|
132
150
|
</CardContent>
|
|
133
151
|
</Card>
|
|
134
152
|
)}
|