@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 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
+ }