@checkmate-monitor/healthcheck-frontend 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 +63 -0
- package/package.json +36 -0
- package/src/api.ts +17 -0
- package/src/auto-charts/AutoChartGrid.tsx +383 -0
- package/src/auto-charts/extension.tsx +27 -0
- package/src/auto-charts/index.ts +12 -0
- package/src/auto-charts/schema-parser.ts +121 -0
- package/src/auto-charts/useStrategySchemas.ts +62 -0
- package/src/components/AggregatedDataBanner.tsx +24 -0
- package/src/components/HealthCheckDiagram.tsx +88 -0
- package/src/components/HealthCheckEditor.tsx +136 -0
- package/src/components/HealthCheckHistory.tsx +79 -0
- package/src/components/HealthCheckLatencyChart.tsx +168 -0
- package/src/components/HealthCheckList.tsx +84 -0
- package/src/components/HealthCheckMenuItems.tsx +27 -0
- package/src/components/HealthCheckRunsTable.tsx +187 -0
- package/src/components/HealthCheckSparkline.tsx +46 -0
- package/src/components/HealthCheckStatusTimeline.tsx +190 -0
- package/src/components/HealthCheckSystemOverview.tsx +328 -0
- package/src/components/SystemHealthBadge.tsx +46 -0
- package/src/components/SystemHealthCheckAssignment.tsx +869 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useHealthCheckData.ts +257 -0
- package/src/index.tsx +100 -0
- package/src/pages/HealthCheckConfigPage.tsx +151 -0
- package/src/pages/HealthCheckHistoryDetailPage.tsx +100 -0
- package/src/pages/HealthCheckHistoryPage.tsx +67 -0
- package/src/slots.tsx +185 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# @checkmate-monitor/healthcheck-frontend
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- ae19ff6: Add configurable state thresholds for health check evaluation
|
|
8
|
+
|
|
9
|
+
**@checkmate-monitor/backend-api:**
|
|
10
|
+
|
|
11
|
+
- Added `VersionedData<T>` generic interface as base for all versioned data structures
|
|
12
|
+
- `VersionedConfig<T>` now extends `VersionedData<T>` and adds `pluginId`
|
|
13
|
+
- Added `migrateVersionedData()` utility function for running migrations on any `VersionedData` subtype
|
|
14
|
+
|
|
15
|
+
**@checkmate-monitor/backend:**
|
|
16
|
+
|
|
17
|
+
- Refactored `ConfigMigrationRunner` to use the new `migrateVersionedData` utility
|
|
18
|
+
|
|
19
|
+
**@checkmate-monitor/healthcheck-common:**
|
|
20
|
+
|
|
21
|
+
- Added state threshold schemas with two evaluation modes (consecutive, window)
|
|
22
|
+
- Added `stateThresholds` field to `AssociateHealthCheckSchema`
|
|
23
|
+
- Added `getSystemHealthStatus` RPC endpoint contract
|
|
24
|
+
|
|
25
|
+
**@checkmate-monitor/healthcheck-backend:**
|
|
26
|
+
|
|
27
|
+
- Added `stateThresholds` column to `system_health_checks` table
|
|
28
|
+
- Added `state-evaluator.ts` with health status evaluation logic
|
|
29
|
+
- Added `state-thresholds-migrations.ts` with migration infrastructure
|
|
30
|
+
- Added `getSystemHealthStatus` RPC handler
|
|
31
|
+
|
|
32
|
+
**@checkmate-monitor/healthcheck-frontend:**
|
|
33
|
+
|
|
34
|
+
- Updated `SystemHealthBadge` to use new backend endpoint
|
|
35
|
+
|
|
36
|
+
- 0babb9c: Add public health status access and detailed history for admins
|
|
37
|
+
|
|
38
|
+
**Permission changes:**
|
|
39
|
+
|
|
40
|
+
- Added `healthcheck.status.read` permission with `isPublicDefault: true` for anonymous access
|
|
41
|
+
- `getSystemHealthStatus`, `getSystemHealthOverview`, and `getHistory` now public
|
|
42
|
+
- `getHistory` no longer returns `result` field (security)
|
|
43
|
+
|
|
44
|
+
**New features:**
|
|
45
|
+
|
|
46
|
+
- Added `getDetailedHistory` endpoint with `healthcheck.manage` permission
|
|
47
|
+
- New `/healthcheck/history` page showing paginated run history with expandable result JSON
|
|
48
|
+
|
|
49
|
+
### Patch Changes
|
|
50
|
+
|
|
51
|
+
- Updated dependencies [eff5b4e]
|
|
52
|
+
- Updated dependencies [ffc28f6]
|
|
53
|
+
- Updated dependencies [4dd644d]
|
|
54
|
+
- Updated dependencies [ae19ff6]
|
|
55
|
+
- Updated dependencies [0babb9c]
|
|
56
|
+
- Updated dependencies [b55fae6]
|
|
57
|
+
- Updated dependencies [b354ab3]
|
|
58
|
+
- @checkmate-monitor/ui@0.1.0
|
|
59
|
+
- @checkmate-monitor/common@0.1.0
|
|
60
|
+
- @checkmate-monitor/catalog-common@0.1.0
|
|
61
|
+
- @checkmate-monitor/healthcheck-common@0.1.0
|
|
62
|
+
- @checkmate-monitor/signal-frontend@0.1.0
|
|
63
|
+
- @checkmate-monitor/frontend-api@0.0.2
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkmate-monitor/healthcheck-frontend",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.tsx",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"typecheck": "tsc --noEmit",
|
|
8
|
+
"lint": "bun run lint:code",
|
|
9
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@checkmate-monitor/catalog-common": "workspace:*",
|
|
13
|
+
"@checkmate-monitor/common": "workspace:*",
|
|
14
|
+
"@checkmate-monitor/frontend-api": "workspace:*",
|
|
15
|
+
"@checkmate-monitor/healthcheck-common": "workspace:*",
|
|
16
|
+
"@checkmate-monitor/signal-frontend": "workspace:*",
|
|
17
|
+
"@checkmate-monitor/ui": "workspace:*",
|
|
18
|
+
"@types/prismjs": "^1.26.5",
|
|
19
|
+
"ajv": "^8.17.1",
|
|
20
|
+
"ajv-formats": "^3.0.1",
|
|
21
|
+
"date-fns": "^4.1.0",
|
|
22
|
+
"lucide-react": "^0.344.0",
|
|
23
|
+
"prismjs": "^1.30.0",
|
|
24
|
+
"react": "^18.2.0",
|
|
25
|
+
"react-router-dom": "^6.20.0",
|
|
26
|
+
"react-simple-code-editor": "^0.14.1",
|
|
27
|
+
"recharts": "^3.6.0",
|
|
28
|
+
"zod": "^4.2.1"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"typescript": "^5.0.0",
|
|
32
|
+
"@types/react": "^18.2.0",
|
|
33
|
+
"@checkmate-monitor/tsconfig": "workspace:*",
|
|
34
|
+
"@checkmate-monitor/scripts": "workspace:*"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createApiRef } from "@checkmate-monitor/frontend-api";
|
|
2
|
+
import { HealthCheckApi } from "@checkmate-monitor/healthcheck-common";
|
|
3
|
+
import type { InferClient } from "@checkmate-monitor/common";
|
|
4
|
+
|
|
5
|
+
// Re-export types for convenience
|
|
6
|
+
export type {
|
|
7
|
+
HealthCheckConfiguration,
|
|
8
|
+
HealthCheckStrategyDto,
|
|
9
|
+
HealthCheckRun,
|
|
10
|
+
HealthCheckRunPublic,
|
|
11
|
+
} from "@checkmate-monitor/healthcheck-common";
|
|
12
|
+
|
|
13
|
+
// HealthCheckApiClient type inferred from the client definition
|
|
14
|
+
export type HealthCheckApiClient = InferClient<typeof HealthCheckApi>;
|
|
15
|
+
|
|
16
|
+
export const healthCheckApiRef =
|
|
17
|
+
createApiRef<HealthCheckApiClient>("healthcheck-api");
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AutoChartGrid - Renders auto-generated charts based on schema metadata.
|
|
3
|
+
*
|
|
4
|
+
* Parses the strategy's result/aggregated schemas to extract chart metadata
|
|
5
|
+
* and renders appropriate visualizations for each annotated field.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ChartField } from "./schema-parser";
|
|
9
|
+
import { extractChartFields, getFieldValue } from "./schema-parser";
|
|
10
|
+
import { useStrategySchemas } from "./useStrategySchemas";
|
|
11
|
+
import type { HealthCheckDiagramSlotContext } from "../slots";
|
|
12
|
+
import type { StoredHealthCheckResult } from "@checkmate-monitor/healthcheck-common";
|
|
13
|
+
import {
|
|
14
|
+
Card,
|
|
15
|
+
CardContent,
|
|
16
|
+
CardHeader,
|
|
17
|
+
CardTitle,
|
|
18
|
+
} from "@checkmate-monitor/ui";
|
|
19
|
+
|
|
20
|
+
interface AutoChartGridProps {
|
|
21
|
+
context: HealthCheckDiagramSlotContext;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Main component that renders a grid of auto-generated charts.
|
|
26
|
+
*/
|
|
27
|
+
export function AutoChartGrid({ context }: AutoChartGridProps) {
|
|
28
|
+
const { schemas, loading } = useStrategySchemas(context.strategyId);
|
|
29
|
+
|
|
30
|
+
if (loading) {
|
|
31
|
+
return; // Don't show loading state, let custom charts render first
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!schemas) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Choose schema based on context type
|
|
39
|
+
const schema =
|
|
40
|
+
context.type === "raw"
|
|
41
|
+
? schemas.resultSchema
|
|
42
|
+
: schemas.aggregatedResultSchema;
|
|
43
|
+
|
|
44
|
+
if (!schema) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const fields = extractChartFields(schema);
|
|
49
|
+
if (fields.length === 0) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 mt-4">
|
|
55
|
+
{fields.map((field) => (
|
|
56
|
+
<AutoChartCard key={field.name} field={field} context={context} />
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface AutoChartCardProps {
|
|
63
|
+
field: ChartField;
|
|
64
|
+
context: HealthCheckDiagramSlotContext;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Individual chart card that renders based on field type.
|
|
69
|
+
*/
|
|
70
|
+
function AutoChartCard({ field, context }: AutoChartCardProps) {
|
|
71
|
+
return (
|
|
72
|
+
<Card>
|
|
73
|
+
<CardHeader className="pb-2">
|
|
74
|
+
<CardTitle className="text-sm font-medium">{field.label}</CardTitle>
|
|
75
|
+
</CardHeader>
|
|
76
|
+
<CardContent>
|
|
77
|
+
<ChartRenderer field={field} context={context} />
|
|
78
|
+
</CardContent>
|
|
79
|
+
</Card>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface ChartRendererProps {
|
|
84
|
+
field: ChartField;
|
|
85
|
+
context: HealthCheckDiagramSlotContext;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Dispatches to appropriate chart renderer based on chart type.
|
|
90
|
+
*/
|
|
91
|
+
function ChartRenderer({ field, context }: ChartRendererProps) {
|
|
92
|
+
switch (field.chartType) {
|
|
93
|
+
case "line": {
|
|
94
|
+
return <LineChartRenderer field={field} context={context} />;
|
|
95
|
+
}
|
|
96
|
+
case "gauge": {
|
|
97
|
+
return <GaugeRenderer field={field} context={context} />;
|
|
98
|
+
}
|
|
99
|
+
case "counter": {
|
|
100
|
+
return <CounterRenderer field={field} context={context} />;
|
|
101
|
+
}
|
|
102
|
+
case "bar": {
|
|
103
|
+
return <BarChartRenderer field={field} context={context} />;
|
|
104
|
+
}
|
|
105
|
+
case "boolean": {
|
|
106
|
+
return <BooleanRenderer field={field} context={context} />;
|
|
107
|
+
}
|
|
108
|
+
case "text": {
|
|
109
|
+
return <TextRenderer field={field} context={context} />;
|
|
110
|
+
}
|
|
111
|
+
case "status": {
|
|
112
|
+
return <StatusRenderer field={field} context={context} />;
|
|
113
|
+
}
|
|
114
|
+
default: {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// =============================================================================
|
|
121
|
+
// CHART RENDERERS
|
|
122
|
+
// =============================================================================
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Renders a large counter value with optional trend.
|
|
126
|
+
*/
|
|
127
|
+
function CounterRenderer({ field, context }: ChartRendererProps) {
|
|
128
|
+
const value = getLatestValue(field.name, context);
|
|
129
|
+
const displayValue = typeof value === "number" ? value : "—";
|
|
130
|
+
const unit = field.unit ?? "";
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="text-2xl font-bold">
|
|
134
|
+
{displayValue}
|
|
135
|
+
{unit && (
|
|
136
|
+
<span className="text-sm font-normal text-muted-foreground ml-1">
|
|
137
|
+
{unit}
|
|
138
|
+
</span>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Renders a percentage gauge visualization.
|
|
146
|
+
*/
|
|
147
|
+
function GaugeRenderer({ field, context }: ChartRendererProps) {
|
|
148
|
+
const value = getLatestValue(field.name, context);
|
|
149
|
+
const numValue =
|
|
150
|
+
typeof value === "number" ? Math.min(100, Math.max(0, value)) : 0;
|
|
151
|
+
const unit = field.unit ?? "%";
|
|
152
|
+
|
|
153
|
+
// Determine color based on value (for rates: higher is better)
|
|
154
|
+
const colorClass =
|
|
155
|
+
numValue >= 90
|
|
156
|
+
? "text-green-500"
|
|
157
|
+
: numValue >= 70
|
|
158
|
+
? "text-yellow-500"
|
|
159
|
+
: "text-red-500";
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div className="flex items-center gap-3">
|
|
163
|
+
<div className="relative w-16 h-16">
|
|
164
|
+
<svg className="w-16 h-16 transform -rotate-90" viewBox="0 0 36 36">
|
|
165
|
+
<circle
|
|
166
|
+
cx="18"
|
|
167
|
+
cy="18"
|
|
168
|
+
r="15.5"
|
|
169
|
+
fill="none"
|
|
170
|
+
className="stroke-muted"
|
|
171
|
+
strokeWidth="3"
|
|
172
|
+
/>
|
|
173
|
+
<circle
|
|
174
|
+
cx="18"
|
|
175
|
+
cy="18"
|
|
176
|
+
r="15.5"
|
|
177
|
+
fill="none"
|
|
178
|
+
className={colorClass.replace("text-", "stroke-")}
|
|
179
|
+
strokeWidth="3"
|
|
180
|
+
strokeDasharray={`${numValue} 100`}
|
|
181
|
+
strokeLinecap="round"
|
|
182
|
+
/>
|
|
183
|
+
</svg>
|
|
184
|
+
</div>
|
|
185
|
+
<div className={`text-2xl font-bold ${colorClass}`}>
|
|
186
|
+
{numValue.toFixed(1)}
|
|
187
|
+
{unit}
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Renders a boolean indicator (success/failure).
|
|
195
|
+
*/
|
|
196
|
+
function BooleanRenderer({ field, context }: ChartRendererProps) {
|
|
197
|
+
const value = getLatestValue(field.name, context);
|
|
198
|
+
const isTrue = value === true;
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<div className="flex items-center gap-2">
|
|
202
|
+
<div
|
|
203
|
+
className={`w-3 h-3 rounded-full ${
|
|
204
|
+
isTrue ? "bg-green-500" : "bg-red-500"
|
|
205
|
+
}`}
|
|
206
|
+
/>
|
|
207
|
+
<span className={isTrue ? "text-green-600" : "text-red-600"}>
|
|
208
|
+
{isTrue ? "Yes" : "No"}
|
|
209
|
+
</span>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Renders text value.
|
|
216
|
+
*/
|
|
217
|
+
function TextRenderer({ field, context }: ChartRendererProps) {
|
|
218
|
+
const value = getLatestValue(field.name, context);
|
|
219
|
+
const displayValue = formatTextValue(value);
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<div
|
|
223
|
+
className="text-sm font-mono text-muted-foreground truncate"
|
|
224
|
+
title={displayValue}
|
|
225
|
+
>
|
|
226
|
+
{displayValue || "—"}
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Renders error/status badge.
|
|
233
|
+
*/
|
|
234
|
+
function StatusRenderer({ field, context }: ChartRendererProps) {
|
|
235
|
+
const value = getLatestValue(field.name, context);
|
|
236
|
+
const hasValue = value !== undefined && value !== null && value !== "";
|
|
237
|
+
|
|
238
|
+
if (!hasValue) {
|
|
239
|
+
return <div className="text-sm text-muted-foreground">No errors</div>;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-950 px-2 py-1 rounded truncate">
|
|
244
|
+
{String(value)}
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Renders a simple line chart visualization.
|
|
251
|
+
* For now, shows min/avg/max summary. Full charts can be added later.
|
|
252
|
+
*/
|
|
253
|
+
function LineChartRenderer({ field, context }: ChartRendererProps) {
|
|
254
|
+
const values = getAllValues(field.name, context);
|
|
255
|
+
const unit = field.unit ?? "";
|
|
256
|
+
|
|
257
|
+
if (values.length === 0) {
|
|
258
|
+
return <div className="text-muted-foreground">No data</div>;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const min = Math.min(...values);
|
|
262
|
+
const max = Math.max(...values);
|
|
263
|
+
const avg = values.reduce((a, b) => a + b, 0) / values.length;
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<div className="space-y-1">
|
|
267
|
+
<div className="text-2xl font-bold">
|
|
268
|
+
{avg.toFixed(1)}
|
|
269
|
+
{unit && (
|
|
270
|
+
<span className="text-sm font-normal text-muted-foreground ml-1">
|
|
271
|
+
{unit}
|
|
272
|
+
</span>
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
<div className="text-xs text-muted-foreground">
|
|
276
|
+
Min: {min.toFixed(1)}
|
|
277
|
+
{unit} · Max: {max.toFixed(1)}
|
|
278
|
+
{unit}
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Renders a bar chart for record values.
|
|
286
|
+
*/
|
|
287
|
+
function BarChartRenderer({ field, context }: ChartRendererProps) {
|
|
288
|
+
const value = getLatestValue(field.name, context);
|
|
289
|
+
|
|
290
|
+
if (!value || typeof value !== "object") {
|
|
291
|
+
return <div className="text-muted-foreground">No data</div>;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const entries = Object.entries(value as Record<string, number>).slice(0, 5);
|
|
295
|
+
const maxValue = Math.max(...entries.map(([, v]) => v), 1);
|
|
296
|
+
|
|
297
|
+
return (
|
|
298
|
+
<div className="space-y-2">
|
|
299
|
+
{entries.map(([key, val]) => (
|
|
300
|
+
<div key={key} className="flex items-center gap-2">
|
|
301
|
+
<span className="text-xs w-12 text-right text-muted-foreground">
|
|
302
|
+
{key}
|
|
303
|
+
</span>
|
|
304
|
+
<div className="flex-1 h-4 bg-muted rounded overflow-hidden">
|
|
305
|
+
<div
|
|
306
|
+
className="h-full bg-primary"
|
|
307
|
+
style={{ width: `${(val / maxValue) * 100}%` }}
|
|
308
|
+
/>
|
|
309
|
+
</div>
|
|
310
|
+
<span className="text-xs w-8">{val}</span>
|
|
311
|
+
</div>
|
|
312
|
+
))}
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// =============================================================================
|
|
318
|
+
// HELPER FUNCTIONS
|
|
319
|
+
// =============================================================================
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Get the latest value for a field from the context.
|
|
323
|
+
*
|
|
324
|
+
* For raw runs, the strategy-specific data is inside result.metadata.
|
|
325
|
+
* For aggregated buckets, the data is directly in aggregatedResult.
|
|
326
|
+
*/
|
|
327
|
+
function getLatestValue(
|
|
328
|
+
fieldName: string,
|
|
329
|
+
context: HealthCheckDiagramSlotContext
|
|
330
|
+
): unknown {
|
|
331
|
+
if (context.type === "raw") {
|
|
332
|
+
const runs = context.runs;
|
|
333
|
+
if (runs.length === 0) return undefined;
|
|
334
|
+
// result is typed as StoredHealthCheckResult with { status, latencyMs, message, metadata }
|
|
335
|
+
const result = runs.at(-1)?.result as StoredHealthCheckResult | undefined;
|
|
336
|
+
return getFieldValue(result?.metadata, fieldName);
|
|
337
|
+
} else {
|
|
338
|
+
const buckets = context.buckets;
|
|
339
|
+
if (buckets.length === 0) return undefined;
|
|
340
|
+
return getFieldValue(
|
|
341
|
+
buckets.at(-1)?.aggregatedResult as Record<string, unknown>,
|
|
342
|
+
fieldName
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Get all numeric values for a field from the context.
|
|
349
|
+
*
|
|
350
|
+
* For raw runs, the strategy-specific data is inside result.metadata.
|
|
351
|
+
* For aggregated buckets, the data is directly in aggregatedResult.
|
|
352
|
+
*/
|
|
353
|
+
function getAllValues(
|
|
354
|
+
fieldName: string,
|
|
355
|
+
context: HealthCheckDiagramSlotContext
|
|
356
|
+
): number[] {
|
|
357
|
+
if (context.type === "raw") {
|
|
358
|
+
return context.runs
|
|
359
|
+
.map((run) => {
|
|
360
|
+
// result is typed as StoredHealthCheckResult with { status, latencyMs, message, metadata }
|
|
361
|
+
const result = run.result as StoredHealthCheckResult;
|
|
362
|
+
return getFieldValue(result?.metadata, fieldName);
|
|
363
|
+
})
|
|
364
|
+
.filter((v): v is number => typeof v === "number");
|
|
365
|
+
}
|
|
366
|
+
return context.buckets
|
|
367
|
+
.map((bucket) =>
|
|
368
|
+
getFieldValue(
|
|
369
|
+
bucket.aggregatedResult as Record<string, unknown>,
|
|
370
|
+
fieldName
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
.filter((v): v is number => typeof v === "number");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Format a value for text display.
|
|
378
|
+
*/
|
|
379
|
+
function formatTextValue(value: unknown): string {
|
|
380
|
+
if (value === undefined || value === null) return "";
|
|
381
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
382
|
+
return String(value);
|
|
383
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-chart slot extension registration.
|
|
3
|
+
*
|
|
4
|
+
* Registers the AutoChartGrid as a diagram extension that renders
|
|
5
|
+
* for all strategies that have schema metadata.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createSlotExtension } from "@checkmate-monitor/frontend-api";
|
|
9
|
+
import {
|
|
10
|
+
HealthCheckDiagramSlot,
|
|
11
|
+
type HealthCheckDiagramSlotContext,
|
|
12
|
+
} from "../slots";
|
|
13
|
+
import { AutoChartGrid } from "./AutoChartGrid";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Extension that renders auto-generated charts for any strategy.
|
|
17
|
+
*
|
|
18
|
+
* Unlike custom chart extensions that filter by strategy ID, this extension
|
|
19
|
+
* renders for all strategies and lets AutoChartGrid decide what to display
|
|
20
|
+
* based on the schema metadata.
|
|
21
|
+
*/
|
|
22
|
+
export const autoChartExtension = createSlotExtension(HealthCheckDiagramSlot, {
|
|
23
|
+
id: "healthcheck.auto-charts",
|
|
24
|
+
component: (context: HealthCheckDiagramSlotContext) => {
|
|
25
|
+
return <AutoChartGrid context={context} />;
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-chart components for rendering schema-driven visualizations.
|
|
3
|
+
*
|
|
4
|
+
* These components render charts based on x-chart-type metadata in JSON schemas,
|
|
5
|
+
* eliminating the need for custom chart components for standard metrics.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { extractChartFields, getFieldValue } from "./schema-parser";
|
|
9
|
+
export type { ChartField, ChartType } from "./schema-parser";
|
|
10
|
+
export { AutoChartGrid } from "./AutoChartGrid";
|
|
11
|
+
export { useStrategySchemas } from "./useStrategySchemas";
|
|
12
|
+
export { autoChartExtension } from "./extension";
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility to extract chart metadata from JSON Schema.
|
|
3
|
+
*
|
|
4
|
+
* Parses JSON Schema objects and extracts x-chart-type, x-chart-label,
|
|
5
|
+
* and x-chart-unit metadata for auto-chart rendering.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Available chart types for auto-generated visualizations.
|
|
10
|
+
* Mirrors the backend ChartType but defined locally since frontend
|
|
11
|
+
* cannot import from backend-api.
|
|
12
|
+
*/
|
|
13
|
+
export type ChartType =
|
|
14
|
+
| "line"
|
|
15
|
+
| "bar"
|
|
16
|
+
| "counter"
|
|
17
|
+
| "gauge"
|
|
18
|
+
| "boolean"
|
|
19
|
+
| "text"
|
|
20
|
+
| "status";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Chart field information extracted from JSON Schema.
|
|
24
|
+
*/
|
|
25
|
+
export interface ChartField {
|
|
26
|
+
/** Field name in the schema */
|
|
27
|
+
name: string;
|
|
28
|
+
/** Chart type to render */
|
|
29
|
+
chartType: ChartType;
|
|
30
|
+
/** Human-readable label (defaults to name) */
|
|
31
|
+
label: string;
|
|
32
|
+
/** Optional unit suffix (e.g., 'ms', '%') */
|
|
33
|
+
unit?: string;
|
|
34
|
+
/** JSON Schema type (number, string, boolean, etc.) */
|
|
35
|
+
schemaType: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* JSON Schema property with potential chart metadata.
|
|
40
|
+
*/
|
|
41
|
+
interface JsonSchemaProperty {
|
|
42
|
+
type?: string;
|
|
43
|
+
"x-chart-type"?: ChartType;
|
|
44
|
+
"x-chart-label"?: string;
|
|
45
|
+
"x-chart-unit"?: string;
|
|
46
|
+
items?: JsonSchemaProperty;
|
|
47
|
+
additionalProperties?: JsonSchemaProperty;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* JSON Schema object structure.
|
|
52
|
+
*/
|
|
53
|
+
interface JsonSchema {
|
|
54
|
+
type?: string;
|
|
55
|
+
properties?: Record<string, JsonSchemaProperty>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract chart fields from a JSON Schema.
|
|
60
|
+
*
|
|
61
|
+
* Looks for properties with x-chart-type metadata and extracts
|
|
62
|
+
* relevant chart configuration.
|
|
63
|
+
*
|
|
64
|
+
* @param schema - JSON Schema object
|
|
65
|
+
* @returns Array of chart fields with metadata
|
|
66
|
+
*/
|
|
67
|
+
export function extractChartFields(
|
|
68
|
+
schema: Record<string, unknown> | null | undefined
|
|
69
|
+
): ChartField[] {
|
|
70
|
+
if (!schema) return [];
|
|
71
|
+
|
|
72
|
+
const typed = schema as JsonSchema;
|
|
73
|
+
if (typed.type !== "object" || !typed.properties) return [];
|
|
74
|
+
|
|
75
|
+
const fields: ChartField[] = [];
|
|
76
|
+
|
|
77
|
+
for (const [name, prop] of Object.entries(typed.properties)) {
|
|
78
|
+
const chartType = prop["x-chart-type"];
|
|
79
|
+
if (!chartType) continue;
|
|
80
|
+
|
|
81
|
+
// Determine the underlying schema type
|
|
82
|
+
let schemaType = prop.type ?? "unknown";
|
|
83
|
+
if (prop.type === "array" && prop.items?.type) {
|
|
84
|
+
schemaType = `array<${prop.items.type}>`;
|
|
85
|
+
}
|
|
86
|
+
if (prop.additionalProperties?.type) {
|
|
87
|
+
schemaType = `record<${prop.additionalProperties.type}>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fields.push({
|
|
91
|
+
name,
|
|
92
|
+
chartType,
|
|
93
|
+
label: prop["x-chart-label"] ?? formatFieldName(name),
|
|
94
|
+
unit: prop["x-chart-unit"],
|
|
95
|
+
schemaType,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return fields;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Convert camelCase or snake_case field name to human-readable label.
|
|
104
|
+
*/
|
|
105
|
+
function formatFieldName(name: string): string {
|
|
106
|
+
return name
|
|
107
|
+
.replaceAll(/([a-z])([A-Z])/g, "$1 $2") // camelCase
|
|
108
|
+
.replaceAll("_", " ") // snake_case
|
|
109
|
+
.replaceAll(/\b\w/g, (c) => c.toUpperCase()); // Capitalize
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get the value for a field from a data object.
|
|
114
|
+
*/
|
|
115
|
+
export function getFieldValue(
|
|
116
|
+
data: Record<string, unknown> | undefined,
|
|
117
|
+
fieldName: string
|
|
118
|
+
): unknown {
|
|
119
|
+
if (!data) return undefined;
|
|
120
|
+
return data[fieldName];
|
|
121
|
+
}
|