@checkstack/healthcheck-frontend 0.1.0 → 0.3.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 +202 -0
- package/package.json +3 -2
- package/src/auto-charts/AutoChartGrid.tsx +256 -23
- package/src/auto-charts/schema-parser.ts +43 -20
- package/src/auto-charts/useStrategySchemas.ts +73 -3
- package/src/components/AssertionBuilder.tsx +18 -3
- package/src/components/CollectorList.tsx +6 -0
- package/src/components/HealthCheckDiagram.tsx +6 -6
- package/src/components/HealthCheckEditor.tsx +18 -1
- package/src/components/HealthCheckMenuItems.tsx +4 -7
- package/src/components/HealthCheckRunsTable.tsx +1 -1
- package/src/components/SystemHealthCheckAssignment.tsx +14 -9
- package/src/hooks/useHealthCheckData.ts +29 -29
- package/src/index.tsx +4 -4
- package/src/pages/HealthCheckConfigPage.tsx +9 -9
- package/src/pages/HealthCheckHistoryDetailPage.tsx +9 -6
- package/src/pages/HealthCheckHistoryPage.tsx +6 -5
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* Supports nested schemas under `collectors.*` for per-collector metrics.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { ChartType } from "@checkstack/healthcheck-common";
|
|
11
10
|
import type {
|
|
11
|
+
ChartType,
|
|
12
12
|
JsonSchemaPropertyCore,
|
|
13
13
|
JsonSchemaBase,
|
|
14
14
|
} from "@checkstack/common";
|
|
@@ -34,7 +34,7 @@ export type ResultSchema = JsonSchemaBase<ResultSchemaProperty>;
|
|
|
34
34
|
* Chart field information extracted from JSON Schema.
|
|
35
35
|
*/
|
|
36
36
|
export interface ChartField {
|
|
37
|
-
/** Field
|
|
37
|
+
/** Field name (simple name for collector fields, path for others) */
|
|
38
38
|
name: string;
|
|
39
39
|
/** Chart type to render */
|
|
40
40
|
chartType: ChartType;
|
|
@@ -44,7 +44,7 @@ export interface ChartField {
|
|
|
44
44
|
unit?: string;
|
|
45
45
|
/** JSON Schema type (number, string, boolean, etc.) */
|
|
46
46
|
schemaType: string;
|
|
47
|
-
/** Collector ID if this field is from a collector */
|
|
47
|
+
/** Collector ID if this field is from a collector (used for data lookup) */
|
|
48
48
|
collectorId?: string;
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -78,7 +78,6 @@ export function extractChartFields(
|
|
|
78
78
|
// Extract fields from the collector's result schema
|
|
79
79
|
const collectorFields = extractFieldsFromProperties(
|
|
80
80
|
collectorProp.properties,
|
|
81
|
-
`collectors.${collectorId}`,
|
|
82
81
|
collectorId
|
|
83
82
|
);
|
|
84
83
|
fields.push(...collectorFields);
|
|
@@ -101,7 +100,6 @@ export function extractChartFields(
|
|
|
101
100
|
*/
|
|
102
101
|
function extractFieldsFromProperties(
|
|
103
102
|
properties: Record<string, ResultSchemaProperty>,
|
|
104
|
-
pathPrefix: string,
|
|
105
103
|
collectorId: string
|
|
106
104
|
): ChartField[] {
|
|
107
105
|
const fields: ChartField[] = [];
|
|
@@ -110,8 +108,8 @@ function extractFieldsFromProperties(
|
|
|
110
108
|
const chartType = prop["x-chart-type"];
|
|
111
109
|
if (!chartType) continue;
|
|
112
110
|
|
|
113
|
-
|
|
114
|
-
const field = extractSingleField(
|
|
111
|
+
// Use just field name - collectorId is stored separately for data lookup
|
|
112
|
+
const field = extractSingleField(fieldName, prop);
|
|
115
113
|
field.collectorId = collectorId;
|
|
116
114
|
// Prefix label with collector ID for clarity
|
|
117
115
|
if (!prop["x-chart-label"]?.includes(collectorId)) {
|
|
@@ -165,28 +163,53 @@ function formatFieldName(name: string): string {
|
|
|
165
163
|
|
|
166
164
|
/**
|
|
167
165
|
* Get the value for a field from a data object.
|
|
168
|
-
*
|
|
166
|
+
* For strategy-level fields, also searches inside collectors as fallback.
|
|
167
|
+
*
|
|
168
|
+
* @param data - The metadata object
|
|
169
|
+
* @param fieldName - Simple field name (no dot notation for collector fields)
|
|
170
|
+
* @param collectorInstanceId - Optional: if provided, looks in collectors[collectorInstanceId]
|
|
169
171
|
*/
|
|
170
172
|
export function getFieldValue(
|
|
171
173
|
data: Record<string, unknown> | undefined,
|
|
172
|
-
fieldName: string
|
|
174
|
+
fieldName: string,
|
|
175
|
+
collectorInstanceId?: string
|
|
173
176
|
): unknown {
|
|
174
177
|
if (!data) return undefined;
|
|
175
178
|
|
|
176
|
-
//
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
+
// If collectorInstanceId is provided, look in that specific collector's data
|
|
180
|
+
if (collectorInstanceId) {
|
|
181
|
+
const collectors = data.collectors as
|
|
182
|
+
| Record<string, Record<string, unknown>>
|
|
183
|
+
| undefined;
|
|
184
|
+
if (collectors && typeof collectors === "object") {
|
|
185
|
+
const collectorData = collectors[collectorInstanceId];
|
|
186
|
+
if (collectorData && typeof collectorData === "object") {
|
|
187
|
+
return collectorData[fieldName];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
179
191
|
}
|
|
180
192
|
|
|
181
|
-
//
|
|
182
|
-
const
|
|
183
|
-
|
|
193
|
+
// For non-collector fields, try direct lookup first
|
|
194
|
+
const directValue = data[fieldName];
|
|
195
|
+
if (directValue !== undefined) {
|
|
196
|
+
return directValue;
|
|
197
|
+
}
|
|
184
198
|
|
|
185
|
-
for (
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
199
|
+
// Fallback: search all collectors for the field (for strategy schema fields)
|
|
200
|
+
const collectors = data.collectors as
|
|
201
|
+
| Record<string, Record<string, unknown>>
|
|
202
|
+
| undefined;
|
|
203
|
+
if (collectors && typeof collectors === "object") {
|
|
204
|
+
for (const collectorData of Object.values(collectors)) {
|
|
205
|
+
if (collectorData && typeof collectorData === "object") {
|
|
206
|
+
const value = collectorData[fieldName];
|
|
207
|
+
if (value !== undefined) {
|
|
208
|
+
return value;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
189
212
|
}
|
|
190
213
|
|
|
191
|
-
return
|
|
214
|
+
return undefined;
|
|
192
215
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hook to fetch and cache strategy schemas.
|
|
3
|
+
*
|
|
4
|
+
* Fetches both strategy result schemas AND collector result schemas,
|
|
5
|
+
* merging them into a unified schema where collector schemas are nested
|
|
6
|
+
* under `properties.collectors.<collectorId>`.
|
|
3
7
|
*/
|
|
4
8
|
|
|
5
9
|
import { useEffect, useState } from "react";
|
|
@@ -14,6 +18,9 @@ interface StrategySchemas {
|
|
|
14
18
|
/**
|
|
15
19
|
* Fetch and cache strategy schemas for auto-chart rendering.
|
|
16
20
|
*
|
|
21
|
+
* Also fetches collector schemas and merges them into the result schema
|
|
22
|
+
* so that chart fields from collectors are properly extracted.
|
|
23
|
+
*
|
|
17
24
|
* @param strategyId - The strategy ID to fetch schemas for
|
|
18
25
|
* @returns Schemas for the strategy, or undefined if not found
|
|
19
26
|
*/
|
|
@@ -30,13 +37,30 @@ export function useStrategySchemas(strategyId: string): {
|
|
|
30
37
|
|
|
31
38
|
async function fetchSchemas() {
|
|
32
39
|
try {
|
|
33
|
-
|
|
40
|
+
// Fetch strategy and collectors in parallel
|
|
41
|
+
const [strategies, collectors] = await Promise.all([
|
|
42
|
+
api.getStrategies(),
|
|
43
|
+
api.getCollectors({ strategyId }),
|
|
44
|
+
]);
|
|
45
|
+
|
|
34
46
|
const strategy = strategies.find((s) => s.id === strategyId);
|
|
35
47
|
|
|
36
48
|
if (!cancelled && strategy) {
|
|
49
|
+
// Build collector schemas object for nesting under resultSchema.properties.collectors
|
|
50
|
+
const collectorProperties: Record<string, unknown> = {};
|
|
51
|
+
for (const collector of collectors) {
|
|
52
|
+
// Use full ID so it matches stored data keys like "healthcheck-http.request"
|
|
53
|
+
collectorProperties[collector.id] = collector.resultSchema;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Merge collector schemas into strategy result schema
|
|
57
|
+
const mergedResultSchema = mergeCollectorSchemas(
|
|
58
|
+
strategy.resultSchema as Record<string, unknown> | undefined,
|
|
59
|
+
collectorProperties
|
|
60
|
+
);
|
|
61
|
+
|
|
37
62
|
setSchemas({
|
|
38
|
-
resultSchema:
|
|
39
|
-
(strategy.resultSchema as Record<string, unknown>) ?? undefined,
|
|
63
|
+
resultSchema: mergedResultSchema,
|
|
40
64
|
aggregatedResultSchema:
|
|
41
65
|
(strategy.aggregatedResultSchema as Record<string, unknown>) ??
|
|
42
66
|
undefined,
|
|
@@ -60,3 +84,49 @@ export function useStrategySchemas(strategyId: string): {
|
|
|
60
84
|
|
|
61
85
|
return { schemas, loading };
|
|
62
86
|
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Merge collector result schemas into a strategy result schema.
|
|
90
|
+
*
|
|
91
|
+
* Creates a schema structure where collectors are nested under
|
|
92
|
+
* `properties.collectors.<collectorId>`, matching the actual data structure
|
|
93
|
+
* stored by the health check executor.
|
|
94
|
+
*/
|
|
95
|
+
function mergeCollectorSchemas(
|
|
96
|
+
strategySchema: Record<string, unknown> | undefined,
|
|
97
|
+
collectorProperties: Record<string, unknown>
|
|
98
|
+
): Record<string, unknown> | undefined {
|
|
99
|
+
// If no collectors, return original schema
|
|
100
|
+
if (Object.keys(collectorProperties).length === 0) {
|
|
101
|
+
return strategySchema;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Build the collectors nested schema
|
|
105
|
+
const collectorsSchema = {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: collectorProperties,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// If no strategy schema, create one just with collectors
|
|
111
|
+
if (!strategySchema) {
|
|
112
|
+
return {
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: {
|
|
115
|
+
collectors: collectorsSchema,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Merge: add collectors to existing properties
|
|
121
|
+
const existingProps = (strategySchema.properties ?? {}) as Record<
|
|
122
|
+
string,
|
|
123
|
+
unknown
|
|
124
|
+
>;
|
|
125
|
+
return {
|
|
126
|
+
...strategySchema,
|
|
127
|
+
properties: {
|
|
128
|
+
...existingProps,
|
|
129
|
+
collectors: collectorsSchema,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -81,6 +81,13 @@ const OPERATORS: Record<FieldType, { value: string; label: string }[]> = {
|
|
|
81
81
|
],
|
|
82
82
|
enum: [{ value: "equals", label: "Equals" }],
|
|
83
83
|
array: [
|
|
84
|
+
{ value: "includes", label: "Includes" },
|
|
85
|
+
{ value: "notIncludes", label: "Not Includes" },
|
|
86
|
+
{ value: "lengthEquals", label: "Length Equals" },
|
|
87
|
+
{ value: "lengthGreaterThan", label: "Length Greater Than" },
|
|
88
|
+
{ value: "lengthLessThan", label: "Length Less Than" },
|
|
89
|
+
{ value: "isEmpty", label: "Is Empty" },
|
|
90
|
+
{ value: "isNotEmpty", label: "Is Not Empty" },
|
|
84
91
|
{ value: "exists", label: "Exists" },
|
|
85
92
|
{ value: "notExists", label: "Not Exists" },
|
|
86
93
|
],
|
|
@@ -98,6 +105,7 @@ const OPERATORS: Record<FieldType, { value: string; label: string }[]> = {
|
|
|
98
105
|
// Operators that don't need a value input
|
|
99
106
|
const VALUE_LESS_OPERATORS = new Set([
|
|
100
107
|
"isEmpty",
|
|
108
|
+
"isNotEmpty",
|
|
101
109
|
"isTrue",
|
|
102
110
|
"isFalse",
|
|
103
111
|
"exists",
|
|
@@ -302,15 +310,22 @@ export const AssertionBuilder: React.FC<AssertionBuilderProps> = ({
|
|
|
302
310
|
<div className="space-y-4">
|
|
303
311
|
{assertions.map((assertion, index) => {
|
|
304
312
|
const field = getFieldByPath(assertion.field);
|
|
305
|
-
|
|
313
|
+
// Safely get operators with fallback to empty array
|
|
314
|
+
const operators = field ? OPERATORS[field.type] ?? [] : [];
|
|
306
315
|
const needsValue = !VALUE_LESS_OPERATORS.has(assertion.operator);
|
|
307
316
|
|
|
317
|
+
// Check if current values match available options (prevents Radix UI crash)
|
|
318
|
+
const fieldValueValid = fields.some((f) => f.path === assertion.field);
|
|
319
|
+
const operatorValueValid = operators.some(
|
|
320
|
+
(op) => op.value === assertion.operator
|
|
321
|
+
);
|
|
322
|
+
|
|
308
323
|
return (
|
|
309
324
|
<div key={index} className="flex items-start gap-2 flex-wrap">
|
|
310
325
|
{/* Field selector */}
|
|
311
326
|
<div className="flex-1 min-w-[120px]">
|
|
312
327
|
<Select
|
|
313
|
-
value={assertion.field}
|
|
328
|
+
value={fieldValueValid ? assertion.field : undefined}
|
|
314
329
|
onValueChange={(v) => handleFieldChange(index, v)}
|
|
315
330
|
>
|
|
316
331
|
<SelectTrigger>
|
|
@@ -340,7 +355,7 @@ export const AssertionBuilder: React.FC<AssertionBuilderProps> = ({
|
|
|
340
355
|
{/* Operator selector */}
|
|
341
356
|
<div className="flex-1 min-w-[100px]">
|
|
342
357
|
<Select
|
|
343
|
-
value={assertion.operator}
|
|
358
|
+
value={operatorValueValid ? assertion.operator : undefined}
|
|
344
359
|
onValueChange={(v) => handleOperatorChange(index, v)}
|
|
345
360
|
>
|
|
346
361
|
<SelectTrigger>
|
|
@@ -100,6 +100,7 @@ export const CollectorList: React.FC<CollectorListProps> = ({
|
|
|
100
100
|
if (!collector) return;
|
|
101
101
|
|
|
102
102
|
const newEntry: CollectorConfigEntry = {
|
|
103
|
+
id: crypto.randomUUID(),
|
|
103
104
|
collectorId,
|
|
104
105
|
config: {},
|
|
105
106
|
assertions: [],
|
|
@@ -111,6 +112,11 @@ export const CollectorList: React.FC<CollectorListProps> = ({
|
|
|
111
112
|
const handleRemove = (index: number) => {
|
|
112
113
|
const updated = [...configuredCollectors];
|
|
113
114
|
updated.splice(index, 1);
|
|
115
|
+
|
|
116
|
+
// Reset validity map to prevent stale entries after index shift
|
|
117
|
+
// The DynamicForm components will re-report their validity on next render
|
|
118
|
+
setValidityMap({});
|
|
119
|
+
|
|
114
120
|
onChange(updated);
|
|
115
121
|
};
|
|
116
122
|
|
|
@@ -44,20 +44,20 @@ export function HealthCheckDiagram({
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* Wrapper that shows
|
|
47
|
+
* Wrapper that shows access message when user lacks access.
|
|
48
48
|
*/
|
|
49
|
-
export function
|
|
50
|
-
|
|
49
|
+
export function HealthCheckDiagramAccessGate({
|
|
50
|
+
hasAccess,
|
|
51
51
|
children,
|
|
52
52
|
}: {
|
|
53
|
-
|
|
53
|
+
hasAccess: boolean;
|
|
54
54
|
children: React.ReactNode;
|
|
55
55
|
}) {
|
|
56
|
-
if (!
|
|
56
|
+
if (!hasAccess) {
|
|
57
57
|
return (
|
|
58
58
|
<InfoBanner variant="info">
|
|
59
59
|
Additional strategy-specific visualizations are available with the
|
|
60
|
-
"Read Health Check Details"
|
|
60
|
+
"Read Health Check Details" access rule.
|
|
61
61
|
</InfoBanner>
|
|
62
62
|
);
|
|
63
63
|
}
|
|
@@ -13,12 +13,14 @@ import {
|
|
|
13
13
|
useToast,
|
|
14
14
|
Dialog,
|
|
15
15
|
DialogContent,
|
|
16
|
+
DialogDescription,
|
|
16
17
|
DialogHeader,
|
|
17
18
|
DialogTitle,
|
|
18
19
|
DialogFooter,
|
|
19
20
|
} from "@checkstack/ui";
|
|
20
21
|
import { useCollectors } from "../hooks/useCollectors";
|
|
21
22
|
import { CollectorList } from "./CollectorList";
|
|
23
|
+
import { TeamAccessEditor } from "@checkstack/auth-frontend";
|
|
22
24
|
|
|
23
25
|
interface HealthCheckEditorProps {
|
|
24
26
|
strategies: HealthCheckStrategyDto[];
|
|
@@ -82,7 +84,7 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
|
|
|
82
84
|
strategyId,
|
|
83
85
|
intervalSeconds: Number.parseInt(interval, 10),
|
|
84
86
|
config,
|
|
85
|
-
collectors
|
|
87
|
+
collectors, // Always send the array, even if empty, to allow clearing
|
|
86
88
|
});
|
|
87
89
|
} catch (error) {
|
|
88
90
|
const message =
|
|
@@ -102,6 +104,11 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
|
|
|
102
104
|
<DialogTitle>
|
|
103
105
|
{initialData ? "Edit Health Check" : "Create Health Check"}
|
|
104
106
|
</DialogTitle>
|
|
107
|
+
<DialogDescription className="sr-only">
|
|
108
|
+
{initialData
|
|
109
|
+
? "Modify the settings for this health check configuration"
|
|
110
|
+
: "Configure a new health check to monitor your services"}
|
|
111
|
+
</DialogDescription>
|
|
105
112
|
</DialogHeader>
|
|
106
113
|
|
|
107
114
|
<div className="space-y-6 py-4 max-h-[70vh] overflow-y-auto">
|
|
@@ -148,6 +155,16 @@ export const HealthCheckEditor: React.FC<HealthCheckEditorProps> = ({
|
|
|
148
155
|
onValidChange={setCollectorsValid}
|
|
149
156
|
/>
|
|
150
157
|
)}
|
|
158
|
+
|
|
159
|
+
{/* Team Access Editor - only shown for existing configurations */}
|
|
160
|
+
{initialData?.id && (
|
|
161
|
+
<TeamAccessEditor
|
|
162
|
+
resourceType="healthcheck.configuration"
|
|
163
|
+
resourceId={initialData.id}
|
|
164
|
+
compact
|
|
165
|
+
expanded
|
|
166
|
+
/>
|
|
167
|
+
)}
|
|
151
168
|
</div>
|
|
152
169
|
|
|
153
170
|
<DialogFooter>
|
|
@@ -3,20 +3,17 @@ import { Link } from "react-router-dom";
|
|
|
3
3
|
import { Activity } from "lucide-react";
|
|
4
4
|
import type { UserMenuItemsContext } from "@checkstack/frontend-api";
|
|
5
5
|
import { DropdownMenuItem } from "@checkstack/ui";
|
|
6
|
-
import {
|
|
6
|
+
import { resolveRoute } from "@checkstack/common";
|
|
7
7
|
import {
|
|
8
8
|
healthcheckRoutes,
|
|
9
|
-
|
|
9
|
+
healthCheckAccess,
|
|
10
10
|
pluginMetadata,
|
|
11
11
|
} from "@checkstack/healthcheck-common";
|
|
12
12
|
|
|
13
13
|
export const HealthCheckMenuItems = ({
|
|
14
|
-
|
|
14
|
+
accessRules: userPerms,
|
|
15
15
|
}: UserMenuItemsContext) => {
|
|
16
|
-
const qualifiedId =
|
|
17
|
-
pluginMetadata,
|
|
18
|
-
permissions.healthCheckRead
|
|
19
|
-
);
|
|
16
|
+
const qualifiedId = `${pluginMetadata.pluginId}.${healthCheckAccess.configuration.read.id}`;
|
|
20
17
|
const canRead = userPerms.includes("*") || userPerms.includes(qualifiedId);
|
|
21
18
|
|
|
22
19
|
if (!canRead) {
|
|
@@ -2,13 +2,14 @@ import React, { useEffect, useState } from "react";
|
|
|
2
2
|
import {
|
|
3
3
|
useApi,
|
|
4
4
|
type SlotContext,
|
|
5
|
-
|
|
5
|
+
accessApiRef,
|
|
6
6
|
} from "@checkstack/frontend-api";
|
|
7
7
|
import { healthCheckApiRef, HealthCheckConfiguration } from "../api";
|
|
8
8
|
import {
|
|
9
9
|
Button,
|
|
10
10
|
Dialog,
|
|
11
11
|
DialogContent,
|
|
12
|
+
DialogDescription,
|
|
12
13
|
DialogHeader,
|
|
13
14
|
DialogTitle,
|
|
14
15
|
DialogFooter,
|
|
@@ -31,6 +32,7 @@ import type { StateThresholds } from "@checkstack/healthcheck-common";
|
|
|
31
32
|
import {
|
|
32
33
|
DEFAULT_STATE_THRESHOLDS,
|
|
33
34
|
healthcheckRoutes,
|
|
35
|
+
healthCheckAccess,
|
|
34
36
|
} from "@checkstack/healthcheck-common";
|
|
35
37
|
import { resolveRoute } from "@checkstack/common";
|
|
36
38
|
import { DEFAULT_RETENTION_CONFIG } from "@checkstack/healthcheck-common";
|
|
@@ -51,10 +53,9 @@ export const SystemHealthCheckAssignment: React.FC<Props> = ({
|
|
|
51
53
|
systemName: _systemName,
|
|
52
54
|
}) => {
|
|
53
55
|
const api = useApi(healthCheckApiRef);
|
|
54
|
-
const
|
|
55
|
-
const { allowed: canManage } =
|
|
56
|
-
|
|
57
|
-
"manage"
|
|
56
|
+
const accessApi = useApi(accessApiRef);
|
|
57
|
+
const { allowed: canManage } = accessApi.useAccess(
|
|
58
|
+
healthCheckAccess.configuration.manage
|
|
58
59
|
);
|
|
59
60
|
const [configs, setConfigs] = useState<HealthCheckConfiguration[]>([]);
|
|
60
61
|
const [associations, setAssociations] = useState<AssociationState[]>([]);
|
|
@@ -79,10 +80,11 @@ export const SystemHealthCheckAssignment: React.FC<Props> = ({
|
|
|
79
80
|
const loadData = async () => {
|
|
80
81
|
setLoading(true);
|
|
81
82
|
try {
|
|
82
|
-
const [allConfigs, systemAssociations] =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
const [{ configurations: allConfigs }, systemAssociations] =
|
|
84
|
+
await Promise.all([
|
|
85
|
+
api.getConfigurations(),
|
|
86
|
+
api.getSystemAssociations({ systemId }),
|
|
87
|
+
]);
|
|
86
88
|
setConfigs(allConfigs);
|
|
87
89
|
setAssociations(systemAssociations);
|
|
88
90
|
} catch (error) {
|
|
@@ -741,6 +743,9 @@ export const SystemHealthCheckAssignment: React.FC<Props> = ({
|
|
|
741
743
|
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
|
|
742
744
|
<DialogHeader>
|
|
743
745
|
<DialogTitle>Health Check Assignments</DialogTitle>
|
|
746
|
+
<DialogDescription className="sr-only">
|
|
747
|
+
Manage health check assignments for this system
|
|
748
|
+
</DialogDescription>
|
|
744
749
|
</DialogHeader>
|
|
745
750
|
|
|
746
751
|
{loading ? (
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useEffect, useState, useMemo, useCallback } from "react";
|
|
2
|
-
import { useApi,
|
|
2
|
+
import { useApi, accessApiRef } from "@checkstack/frontend-api";
|
|
3
3
|
import { healthCheckApiRef } from "../api";
|
|
4
4
|
import {
|
|
5
|
-
|
|
5
|
+
healthCheckAccess,
|
|
6
6
|
DEFAULT_RETENTION_CONFIG,
|
|
7
7
|
type RetentionConfig,
|
|
8
8
|
HEALTH_CHECK_RUN_COMPLETED,
|
|
@@ -36,10 +36,10 @@ interface UseHealthCheckDataResult {
|
|
|
36
36
|
isAggregated: boolean;
|
|
37
37
|
/** The resolved retention config */
|
|
38
38
|
retentionConfig: RetentionConfig;
|
|
39
|
-
/** Whether user has
|
|
40
|
-
|
|
41
|
-
/** Whether
|
|
42
|
-
|
|
39
|
+
/** Whether user has access to view detailed data */
|
|
40
|
+
hasAccess: boolean;
|
|
41
|
+
/** Whether access is still loading */
|
|
42
|
+
accessLoading: boolean;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
@@ -52,14 +52,14 @@ interface UseHealthCheckDataResult {
|
|
|
52
52
|
*
|
|
53
53
|
* @example
|
|
54
54
|
* ```tsx
|
|
55
|
-
* const { context, loading,
|
|
55
|
+
* const { context, loading, hasAccess } = useHealthCheckData({
|
|
56
56
|
* systemId,
|
|
57
57
|
* configurationId,
|
|
58
58
|
* strategyId,
|
|
59
59
|
* dateRange: { startDate, endDate },
|
|
60
60
|
* });
|
|
61
61
|
*
|
|
62
|
-
* if (!
|
|
62
|
+
* if (!hasAccess) return <NoAccessMessage />;
|
|
63
63
|
* if (loading) return <LoadingSpinner />;
|
|
64
64
|
* if (!context) return null;
|
|
65
65
|
*
|
|
@@ -75,11 +75,11 @@ export function useHealthCheckData({
|
|
|
75
75
|
offset = 0,
|
|
76
76
|
}: UseHealthCheckDataProps): UseHealthCheckDataResult {
|
|
77
77
|
const api = useApi(healthCheckApiRef);
|
|
78
|
-
const
|
|
78
|
+
const accessApi = useApi(accessApiRef);
|
|
79
79
|
|
|
80
|
-
//
|
|
81
|
-
const { allowed:
|
|
82
|
-
|
|
80
|
+
// Access state
|
|
81
|
+
const { allowed: hasAccess, loading: accessLoading } =
|
|
82
|
+
accessApi.useAccess(healthCheckAccess.details);
|
|
83
83
|
|
|
84
84
|
// Retention config state
|
|
85
85
|
const [retentionConfig, setRetentionConfig] = useState<RetentionConfig>(
|
|
@@ -160,24 +160,24 @@ export function useHealthCheckData({
|
|
|
160
160
|
|
|
161
161
|
// Fetch raw data when in raw mode
|
|
162
162
|
useEffect(() => {
|
|
163
|
-
if (!
|
|
163
|
+
if (!hasAccess || accessLoading || retentionLoading || isAggregated)
|
|
164
164
|
return;
|
|
165
165
|
fetchRawData(true);
|
|
166
166
|
}, [
|
|
167
167
|
fetchRawData,
|
|
168
|
-
|
|
169
|
-
|
|
168
|
+
hasAccess,
|
|
169
|
+
accessLoading,
|
|
170
170
|
retentionLoading,
|
|
171
171
|
isAggregated,
|
|
172
172
|
]);
|
|
173
173
|
|
|
174
174
|
// Listen for realtime health check updates to refresh data silently
|
|
175
175
|
useSignal(HEALTH_CHECK_RUN_COMPLETED, ({ systemId: changedId }) => {
|
|
176
|
-
// Only refresh if we're in raw mode (not aggregated) and have
|
|
176
|
+
// Only refresh if we're in raw mode (not aggregated) and have access
|
|
177
177
|
if (
|
|
178
178
|
changedId === systemId &&
|
|
179
|
-
|
|
180
|
-
!
|
|
179
|
+
hasAccess &&
|
|
180
|
+
!accessLoading &&
|
|
181
181
|
!retentionLoading &&
|
|
182
182
|
!isAggregated
|
|
183
183
|
) {
|
|
@@ -188,8 +188,8 @@ export function useHealthCheckData({
|
|
|
188
188
|
// Fetch aggregated data when in aggregated mode
|
|
189
189
|
useEffect(() => {
|
|
190
190
|
if (
|
|
191
|
-
!
|
|
192
|
-
|
|
191
|
+
!hasAccess ||
|
|
192
|
+
accessLoading ||
|
|
193
193
|
retentionLoading ||
|
|
194
194
|
!isAggregated
|
|
195
195
|
)
|
|
@@ -198,7 +198,7 @@ export function useHealthCheckData({
|
|
|
198
198
|
setAggregatedLoading(true);
|
|
199
199
|
// Use daily buckets for ranges > 30 days, hourly otherwise
|
|
200
200
|
const bucketSize = dateRangeDays > 30 ? "daily" : "hourly";
|
|
201
|
-
// Use detailed endpoint to get aggregatedResult since we have
|
|
201
|
+
// Use detailed endpoint to get aggregatedResult since we have access
|
|
202
202
|
api
|
|
203
203
|
.getDetailedAggregatedHistory({
|
|
204
204
|
systemId,
|
|
@@ -217,8 +217,8 @@ export function useHealthCheckData({
|
|
|
217
217
|
api,
|
|
218
218
|
systemId,
|
|
219
219
|
configurationId,
|
|
220
|
-
|
|
221
|
-
|
|
220
|
+
hasAccess,
|
|
221
|
+
accessLoading,
|
|
222
222
|
retentionLoading,
|
|
223
223
|
isAggregated,
|
|
224
224
|
dateRangeDays,
|
|
@@ -227,7 +227,7 @@ export function useHealthCheckData({
|
|
|
227
227
|
]);
|
|
228
228
|
|
|
229
229
|
const context = useMemo((): HealthCheckDiagramSlotContext | undefined => {
|
|
230
|
-
if (!
|
|
230
|
+
if (!hasAccess || accessLoading || retentionLoading) {
|
|
231
231
|
return undefined;
|
|
232
232
|
}
|
|
233
233
|
|
|
@@ -249,8 +249,8 @@ export function useHealthCheckData({
|
|
|
249
249
|
runs: rawRuns,
|
|
250
250
|
};
|
|
251
251
|
}, [
|
|
252
|
-
|
|
253
|
-
|
|
252
|
+
hasAccess,
|
|
253
|
+
accessLoading,
|
|
254
254
|
retentionLoading,
|
|
255
255
|
isAggregated,
|
|
256
256
|
systemId,
|
|
@@ -261,7 +261,7 @@ export function useHealthCheckData({
|
|
|
261
261
|
]);
|
|
262
262
|
|
|
263
263
|
const loading =
|
|
264
|
-
|
|
264
|
+
accessLoading ||
|
|
265
265
|
retentionLoading ||
|
|
266
266
|
(isAggregated ? aggregatedLoading : rawLoading);
|
|
267
267
|
|
|
@@ -270,7 +270,7 @@ export function useHealthCheckData({
|
|
|
270
270
|
loading,
|
|
271
271
|
isAggregated,
|
|
272
272
|
retentionConfig,
|
|
273
|
-
|
|
274
|
-
|
|
273
|
+
hasAccess,
|
|
274
|
+
accessLoading,
|
|
275
275
|
};
|
|
276
276
|
}
|
package/src/index.tsx
CHANGED
|
@@ -13,7 +13,7 @@ import { HealthCheckMenuItems } from "./components/HealthCheckMenuItems";
|
|
|
13
13
|
import { HealthCheckSystemOverview } from "./components/HealthCheckSystemOverview";
|
|
14
14
|
import { SystemHealthCheckAssignment } from "./components/SystemHealthCheckAssignment";
|
|
15
15
|
import { SystemHealthBadge } from "./components/SystemHealthBadge";
|
|
16
|
-
import {
|
|
16
|
+
import { healthCheckAccess } from "@checkstack/healthcheck-common";
|
|
17
17
|
import { autoChartExtension } from "./auto-charts";
|
|
18
18
|
|
|
19
19
|
import {
|
|
@@ -49,19 +49,19 @@ export default createFrontendPlugin({
|
|
|
49
49
|
route: healthcheckRoutes.routes.config,
|
|
50
50
|
element: <HealthCheckConfigPage />,
|
|
51
51
|
title: "Health Checks",
|
|
52
|
-
|
|
52
|
+
accessRule: healthCheckAccess.configuration.manage,
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
route: healthcheckRoutes.routes.history,
|
|
56
56
|
element: <HealthCheckHistoryPage />,
|
|
57
57
|
title: "Health Check History",
|
|
58
|
-
|
|
58
|
+
accessRule: healthCheckAccess.configuration.read,
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
route: healthcheckRoutes.routes.historyDetail,
|
|
62
62
|
element: <HealthCheckHistoryDetailPage />,
|
|
63
63
|
title: "Health Check Detail",
|
|
64
|
-
|
|
64
|
+
accessRule: healthCheckAccess.details,
|
|
65
65
|
},
|
|
66
66
|
],
|
|
67
67
|
apis: [
|