@checkstack/healthcheck-frontend 0.0.2
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 +135 -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 +33 -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 +380 -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 +99 -0
- package/src/pages/HealthCheckConfigPage.tsx +164 -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
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
useApi,
|
|
4
|
+
type SlotContext,
|
|
5
|
+
permissionApiRef,
|
|
6
|
+
} from "@checkstack/frontend-api";
|
|
7
|
+
import { healthCheckApiRef, HealthCheckConfiguration } from "../api";
|
|
8
|
+
import {
|
|
9
|
+
Button,
|
|
10
|
+
Dialog,
|
|
11
|
+
DialogContent,
|
|
12
|
+
DialogHeader,
|
|
13
|
+
DialogTitle,
|
|
14
|
+
DialogFooter,
|
|
15
|
+
Checkbox,
|
|
16
|
+
Label,
|
|
17
|
+
LoadingSpinner,
|
|
18
|
+
useToast,
|
|
19
|
+
Select,
|
|
20
|
+
SelectContent,
|
|
21
|
+
SelectItem,
|
|
22
|
+
SelectTrigger,
|
|
23
|
+
SelectValue,
|
|
24
|
+
Input,
|
|
25
|
+
Tooltip,
|
|
26
|
+
} from "@checkstack/ui";
|
|
27
|
+
import { Activity, Settings2, History, Database } from "lucide-react";
|
|
28
|
+
import { Link } from "react-router-dom";
|
|
29
|
+
import { CatalogSystemActionsSlot } from "@checkstack/catalog-common";
|
|
30
|
+
import type { StateThresholds } from "@checkstack/healthcheck-common";
|
|
31
|
+
import {
|
|
32
|
+
DEFAULT_STATE_THRESHOLDS,
|
|
33
|
+
healthcheckRoutes,
|
|
34
|
+
} from "@checkstack/healthcheck-common";
|
|
35
|
+
import { resolveRoute } from "@checkstack/common";
|
|
36
|
+
import { DEFAULT_RETENTION_CONFIG } from "@checkstack/healthcheck-common";
|
|
37
|
+
|
|
38
|
+
type SelectedPanel = { configId: string; panel: "thresholds" | "retention" };
|
|
39
|
+
|
|
40
|
+
type Props = SlotContext<typeof CatalogSystemActionsSlot>;
|
|
41
|
+
|
|
42
|
+
interface AssociationState {
|
|
43
|
+
configurationId: string;
|
|
44
|
+
configurationName: string;
|
|
45
|
+
enabled: boolean;
|
|
46
|
+
stateThresholds?: StateThresholds;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const SystemHealthCheckAssignment: React.FC<Props> = ({
|
|
50
|
+
systemId,
|
|
51
|
+
systemName: _systemName,
|
|
52
|
+
}) => {
|
|
53
|
+
const api = useApi(healthCheckApiRef);
|
|
54
|
+
const permissionApi = useApi(permissionApiRef);
|
|
55
|
+
const { allowed: canManage } = permissionApi.useResourcePermission(
|
|
56
|
+
"healthcheck",
|
|
57
|
+
"manage"
|
|
58
|
+
);
|
|
59
|
+
const [configs, setConfigs] = useState<HealthCheckConfiguration[]>([]);
|
|
60
|
+
const [associations, setAssociations] = useState<AssociationState[]>([]);
|
|
61
|
+
const [loading, setLoading] = useState(true);
|
|
62
|
+
const [saving, setSaving] = useState(false);
|
|
63
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
64
|
+
const [selectedPanel, setSelectedPanel] = useState<SelectedPanel>();
|
|
65
|
+
const [retentionData, setRetentionData] = useState<
|
|
66
|
+
Record<
|
|
67
|
+
string,
|
|
68
|
+
{
|
|
69
|
+
rawRetentionDays: number;
|
|
70
|
+
hourlyRetentionDays: number;
|
|
71
|
+
dailyRetentionDays: number;
|
|
72
|
+
isCustom: boolean;
|
|
73
|
+
loading: boolean;
|
|
74
|
+
}
|
|
75
|
+
>
|
|
76
|
+
>({});
|
|
77
|
+
const toast = useToast();
|
|
78
|
+
|
|
79
|
+
const loadData = async () => {
|
|
80
|
+
setLoading(true);
|
|
81
|
+
try {
|
|
82
|
+
const [allConfigs, systemAssociations] = await Promise.all([
|
|
83
|
+
api.getConfigurations(),
|
|
84
|
+
api.getSystemAssociations({ systemId }),
|
|
85
|
+
]);
|
|
86
|
+
setConfigs(allConfigs);
|
|
87
|
+
setAssociations(systemAssociations);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const message =
|
|
90
|
+
error instanceof Error ? error.message : "Failed to load data";
|
|
91
|
+
toast.error(message);
|
|
92
|
+
} finally {
|
|
93
|
+
setLoading(false);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Load association count on mount (for button badge)
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
api.getSystemAssociations({ systemId }).then(setAssociations);
|
|
100
|
+
}, [api, systemId]);
|
|
101
|
+
|
|
102
|
+
// Load full data when dialog opens
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (isOpen) {
|
|
105
|
+
loadData();
|
|
106
|
+
}
|
|
107
|
+
}, [systemId, isOpen]);
|
|
108
|
+
|
|
109
|
+
const handleToggleAssignment = async (
|
|
110
|
+
configId: string,
|
|
111
|
+
isCurrentlyAssigned: boolean
|
|
112
|
+
) => {
|
|
113
|
+
const config = configs.find((c) => c.id === configId);
|
|
114
|
+
if (!config) return;
|
|
115
|
+
|
|
116
|
+
setSaving(true);
|
|
117
|
+
try {
|
|
118
|
+
if (isCurrentlyAssigned) {
|
|
119
|
+
await api.disassociateSystem({ systemId, configId });
|
|
120
|
+
setAssociations((prev) =>
|
|
121
|
+
prev.filter((a) => a.configurationId !== configId)
|
|
122
|
+
);
|
|
123
|
+
} else {
|
|
124
|
+
await api.associateSystem({
|
|
125
|
+
systemId,
|
|
126
|
+
body: {
|
|
127
|
+
configurationId: configId,
|
|
128
|
+
enabled: true,
|
|
129
|
+
stateThresholds: DEFAULT_STATE_THRESHOLDS,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
setAssociations((prev) => [
|
|
133
|
+
...prev,
|
|
134
|
+
{
|
|
135
|
+
configurationId: configId,
|
|
136
|
+
configurationName: config.name,
|
|
137
|
+
enabled: true,
|
|
138
|
+
stateThresholds: DEFAULT_STATE_THRESHOLDS,
|
|
139
|
+
},
|
|
140
|
+
]);
|
|
141
|
+
}
|
|
142
|
+
} catch (error) {
|
|
143
|
+
const message =
|
|
144
|
+
error instanceof Error ? error.message : "Failed to update";
|
|
145
|
+
toast.error(message);
|
|
146
|
+
} finally {
|
|
147
|
+
setSaving(false);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const handleThresholdChange = (
|
|
152
|
+
configId: string,
|
|
153
|
+
thresholds: StateThresholds
|
|
154
|
+
) => {
|
|
155
|
+
setAssociations((prev) =>
|
|
156
|
+
prev.map((a) =>
|
|
157
|
+
a.configurationId === configId
|
|
158
|
+
? { ...a, stateThresholds: thresholds }
|
|
159
|
+
: a
|
|
160
|
+
)
|
|
161
|
+
);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const handleSaveThresholds = async (configId: string) => {
|
|
165
|
+
const assoc = associations.find((a) => a.configurationId === configId);
|
|
166
|
+
if (!assoc) return;
|
|
167
|
+
|
|
168
|
+
setSaving(true);
|
|
169
|
+
try {
|
|
170
|
+
await api.associateSystem({
|
|
171
|
+
systemId,
|
|
172
|
+
body: {
|
|
173
|
+
configurationId: configId,
|
|
174
|
+
enabled: assoc.enabled,
|
|
175
|
+
stateThresholds: assoc.stateThresholds,
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
toast.success("Thresholds saved");
|
|
179
|
+
setSelectedPanel(undefined);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
const message = error instanceof Error ? error.message : "Failed to save";
|
|
182
|
+
toast.error(message);
|
|
183
|
+
} finally {
|
|
184
|
+
setSaving(false);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const assignedIds = associations.map((a) => a.configurationId);
|
|
189
|
+
|
|
190
|
+
const renderThresholdEditor = (assoc: AssociationState) => {
|
|
191
|
+
const thresholds = assoc.stateThresholds || DEFAULT_STATE_THRESHOLDS;
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div className="mt-4 space-y-4">
|
|
195
|
+
{/* Mode Selector */}
|
|
196
|
+
<div className="p-4 bg-muted/50 rounded-lg border">
|
|
197
|
+
<div className="flex items-center gap-2 mb-2">
|
|
198
|
+
<Label className="text-sm font-medium">Evaluation Mode</Label>
|
|
199
|
+
<Tooltip content="How health status is calculated based on check results" />
|
|
200
|
+
</div>
|
|
201
|
+
<Select
|
|
202
|
+
value={thresholds.mode}
|
|
203
|
+
onValueChange={(value: "consecutive" | "window") => {
|
|
204
|
+
if (value === "consecutive") {
|
|
205
|
+
handleThresholdChange(assoc.configurationId, {
|
|
206
|
+
mode: "consecutive",
|
|
207
|
+
healthy: { minSuccessCount: 1 },
|
|
208
|
+
degraded: { minFailureCount: 2 },
|
|
209
|
+
unhealthy: { minFailureCount: 5 },
|
|
210
|
+
});
|
|
211
|
+
} else {
|
|
212
|
+
handleThresholdChange(assoc.configurationId, {
|
|
213
|
+
mode: "window",
|
|
214
|
+
windowSize: 10,
|
|
215
|
+
degraded: { minFailureCount: 3 },
|
|
216
|
+
unhealthy: { minFailureCount: 7 },
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}}
|
|
220
|
+
>
|
|
221
|
+
<SelectTrigger className="w-full">
|
|
222
|
+
<SelectValue />
|
|
223
|
+
</SelectTrigger>
|
|
224
|
+
<SelectContent>
|
|
225
|
+
<SelectItem value="consecutive">
|
|
226
|
+
Consecutive (streak-based)
|
|
227
|
+
</SelectItem>
|
|
228
|
+
<SelectItem value="window">
|
|
229
|
+
Window (count in last N runs)
|
|
230
|
+
</SelectItem>
|
|
231
|
+
</SelectContent>
|
|
232
|
+
</Select>
|
|
233
|
+
<p className="text-xs text-muted-foreground mt-2">
|
|
234
|
+
{thresholds.mode === "consecutive"
|
|
235
|
+
? "Status changes when a streak of consecutive results is reached."
|
|
236
|
+
: "Status is based on how many failures occur within a rolling window."}
|
|
237
|
+
</p>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
{/* Threshold Configuration Cards */}
|
|
241
|
+
{thresholds.mode === "consecutive" ? (
|
|
242
|
+
<div className="space-y-3">
|
|
243
|
+
{/* Healthy Threshold */}
|
|
244
|
+
<div className="p-3 rounded-lg border border-success/30 bg-success/5">
|
|
245
|
+
<div className="flex items-center justify-between">
|
|
246
|
+
<div className="flex items-center gap-2">
|
|
247
|
+
<div className="h-2 w-2 rounded-full bg-success" />
|
|
248
|
+
<span className="text-sm font-medium text-success">
|
|
249
|
+
Healthy
|
|
250
|
+
</span>
|
|
251
|
+
<Tooltip content="System returns to healthy after this many consecutive successful checks" />
|
|
252
|
+
</div>
|
|
253
|
+
<div className="flex items-center gap-2">
|
|
254
|
+
<Input
|
|
255
|
+
type="number"
|
|
256
|
+
min={1}
|
|
257
|
+
value={thresholds.healthy.minSuccessCount}
|
|
258
|
+
onChange={(e) =>
|
|
259
|
+
handleThresholdChange(assoc.configurationId, {
|
|
260
|
+
...thresholds,
|
|
261
|
+
healthy: {
|
|
262
|
+
minSuccessCount: Number.parseInt(e.target.value) || 1,
|
|
263
|
+
},
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
className="h-8 w-16 text-center"
|
|
267
|
+
/>
|
|
268
|
+
<span className="text-xs text-muted-foreground w-20">
|
|
269
|
+
consecutive ✓
|
|
270
|
+
</span>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
{/* Degraded Threshold */}
|
|
276
|
+
<div className="p-3 rounded-lg border border-warning/30 bg-warning/5">
|
|
277
|
+
<div className="flex items-center justify-between">
|
|
278
|
+
<div className="flex items-center gap-2">
|
|
279
|
+
<div className="h-2 w-2 rounded-full bg-warning" />
|
|
280
|
+
<span className="text-sm font-medium text-warning">
|
|
281
|
+
Degraded
|
|
282
|
+
</span>
|
|
283
|
+
<Tooltip content="System becomes degraded after this many consecutive failures" />
|
|
284
|
+
</div>
|
|
285
|
+
<div className="flex items-center gap-2">
|
|
286
|
+
<Input
|
|
287
|
+
type="number"
|
|
288
|
+
min={1}
|
|
289
|
+
value={thresholds.degraded.minFailureCount}
|
|
290
|
+
onChange={(e) =>
|
|
291
|
+
handleThresholdChange(assoc.configurationId, {
|
|
292
|
+
...thresholds,
|
|
293
|
+
degraded: {
|
|
294
|
+
minFailureCount: Number.parseInt(e.target.value) || 1,
|
|
295
|
+
},
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
className="h-8 w-16 text-center"
|
|
299
|
+
/>
|
|
300
|
+
<span className="text-xs text-muted-foreground w-20">
|
|
301
|
+
consecutive ✗
|
|
302
|
+
</span>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
{/* Unhealthy Threshold */}
|
|
308
|
+
<div className="p-3 rounded-lg border border-destructive/30 bg-destructive/5">
|
|
309
|
+
<div className="flex items-center justify-between">
|
|
310
|
+
<div className="flex items-center gap-2">
|
|
311
|
+
<div className="h-2 w-2 rounded-full bg-destructive" />
|
|
312
|
+
<span className="text-sm font-medium text-destructive">
|
|
313
|
+
Unhealthy
|
|
314
|
+
</span>
|
|
315
|
+
<Tooltip content="System becomes unhealthy after this many consecutive failures" />
|
|
316
|
+
</div>
|
|
317
|
+
<div className="flex items-center gap-2">
|
|
318
|
+
<Input
|
|
319
|
+
type="number"
|
|
320
|
+
min={1}
|
|
321
|
+
value={thresholds.unhealthy.minFailureCount}
|
|
322
|
+
onChange={(e) =>
|
|
323
|
+
handleThresholdChange(assoc.configurationId, {
|
|
324
|
+
...thresholds,
|
|
325
|
+
unhealthy: {
|
|
326
|
+
minFailureCount: Number.parseInt(e.target.value) || 1,
|
|
327
|
+
},
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
className="h-8 w-16 text-center"
|
|
331
|
+
/>
|
|
332
|
+
<span className="text-xs text-muted-foreground w-20">
|
|
333
|
+
consecutive ✗
|
|
334
|
+
</span>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
) : (
|
|
340
|
+
<div className="space-y-3">
|
|
341
|
+
{/* Window Size */}
|
|
342
|
+
<div className="p-3 rounded-lg border bg-muted/30">
|
|
343
|
+
<div className="flex items-center justify-between">
|
|
344
|
+
<div className="flex items-center gap-2">
|
|
345
|
+
<span className="text-sm font-medium">Window Size</span>
|
|
346
|
+
<Tooltip content="How many recent runs to analyze when calculating status" />
|
|
347
|
+
</div>
|
|
348
|
+
<div className="flex items-center gap-2">
|
|
349
|
+
<Input
|
|
350
|
+
type="number"
|
|
351
|
+
min={3}
|
|
352
|
+
max={100}
|
|
353
|
+
value={thresholds.windowSize}
|
|
354
|
+
onChange={(e) =>
|
|
355
|
+
handleThresholdChange(assoc.configurationId, {
|
|
356
|
+
...thresholds,
|
|
357
|
+
windowSize: Number.parseInt(e.target.value) || 10,
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
className="h-8 w-16 text-center"
|
|
361
|
+
/>
|
|
362
|
+
<span className="text-xs text-muted-foreground">runs</span>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
|
|
367
|
+
{/* Degraded Threshold */}
|
|
368
|
+
<div className="p-3 rounded-lg border border-warning/30 bg-warning/5">
|
|
369
|
+
<div className="flex items-center justify-between">
|
|
370
|
+
<div className="flex items-center gap-2">
|
|
371
|
+
<div className="h-2 w-2 rounded-full bg-warning" />
|
|
372
|
+
<span className="text-sm font-medium text-warning">
|
|
373
|
+
Degraded
|
|
374
|
+
</span>
|
|
375
|
+
<Tooltip content="System becomes degraded when failures in the window reach this count" />
|
|
376
|
+
</div>
|
|
377
|
+
<div className="flex items-center gap-2">
|
|
378
|
+
<span className="text-xs text-muted-foreground">≥</span>
|
|
379
|
+
<Input
|
|
380
|
+
type="number"
|
|
381
|
+
min={1}
|
|
382
|
+
value={thresholds.degraded.minFailureCount}
|
|
383
|
+
onChange={(e) =>
|
|
384
|
+
handleThresholdChange(assoc.configurationId, {
|
|
385
|
+
...thresholds,
|
|
386
|
+
degraded: {
|
|
387
|
+
minFailureCount: Number.parseInt(e.target.value) || 1,
|
|
388
|
+
},
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
className="h-8 w-16 text-center"
|
|
392
|
+
/>
|
|
393
|
+
<span className="text-xs text-muted-foreground">
|
|
394
|
+
failures
|
|
395
|
+
</span>
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
|
|
400
|
+
{/* Unhealthy Threshold */}
|
|
401
|
+
<div className="p-3 rounded-lg border border-destructive/30 bg-destructive/5">
|
|
402
|
+
<div className="flex items-center justify-between">
|
|
403
|
+
<div className="flex items-center gap-2">
|
|
404
|
+
<div className="h-2 w-2 rounded-full bg-destructive" />
|
|
405
|
+
<span className="text-sm font-medium text-destructive">
|
|
406
|
+
Unhealthy
|
|
407
|
+
</span>
|
|
408
|
+
<Tooltip content="System becomes unhealthy when failures in the window reach this count" />
|
|
409
|
+
</div>
|
|
410
|
+
<div className="flex items-center gap-2">
|
|
411
|
+
<span className="text-xs text-muted-foreground">≥</span>
|
|
412
|
+
<Input
|
|
413
|
+
type="number"
|
|
414
|
+
min={1}
|
|
415
|
+
value={thresholds.unhealthy.minFailureCount}
|
|
416
|
+
onChange={(e) =>
|
|
417
|
+
handleThresholdChange(assoc.configurationId, {
|
|
418
|
+
...thresholds,
|
|
419
|
+
unhealthy: {
|
|
420
|
+
minFailureCount: Number.parseInt(e.target.value) || 1,
|
|
421
|
+
},
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
className="h-8 w-16 text-center"
|
|
425
|
+
/>
|
|
426
|
+
<span className="text-xs text-muted-foreground">
|
|
427
|
+
failures
|
|
428
|
+
</span>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
)}
|
|
434
|
+
|
|
435
|
+
{/* Action Buttons */}
|
|
436
|
+
<div className="flex justify-end gap-2 pt-2 border-t">
|
|
437
|
+
<Button
|
|
438
|
+
variant="outline"
|
|
439
|
+
size="sm"
|
|
440
|
+
onClick={() => setSelectedPanel(undefined)}
|
|
441
|
+
>
|
|
442
|
+
Cancel
|
|
443
|
+
</Button>
|
|
444
|
+
<Button
|
|
445
|
+
size="sm"
|
|
446
|
+
onClick={() => handleSaveThresholds(assoc.configurationId)}
|
|
447
|
+
disabled={saving}
|
|
448
|
+
>
|
|
449
|
+
{saving ? "Saving..." : "Save Thresholds"}
|
|
450
|
+
</Button>
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
);
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// Load retention data when retention panel is expanded
|
|
457
|
+
const loadRetentionConfig = async (configId: string) => {
|
|
458
|
+
if (retentionData[configId]) return; // Already loaded
|
|
459
|
+
|
|
460
|
+
setRetentionData((prev) => ({
|
|
461
|
+
...prev,
|
|
462
|
+
[configId]: {
|
|
463
|
+
rawRetentionDays: DEFAULT_RETENTION_CONFIG.rawRetentionDays,
|
|
464
|
+
hourlyRetentionDays: DEFAULT_RETENTION_CONFIG.hourlyRetentionDays,
|
|
465
|
+
dailyRetentionDays: DEFAULT_RETENTION_CONFIG.dailyRetentionDays,
|
|
466
|
+
isCustom: false,
|
|
467
|
+
loading: true,
|
|
468
|
+
},
|
|
469
|
+
}));
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
const response = await api.getRetentionConfig({
|
|
473
|
+
systemId,
|
|
474
|
+
configurationId: configId,
|
|
475
|
+
});
|
|
476
|
+
setRetentionData((prev) => ({
|
|
477
|
+
...prev,
|
|
478
|
+
[configId]: {
|
|
479
|
+
rawRetentionDays:
|
|
480
|
+
response.retentionConfig?.rawRetentionDays ??
|
|
481
|
+
DEFAULT_RETENTION_CONFIG.rawRetentionDays,
|
|
482
|
+
hourlyRetentionDays:
|
|
483
|
+
response.retentionConfig?.hourlyRetentionDays ??
|
|
484
|
+
DEFAULT_RETENTION_CONFIG.hourlyRetentionDays,
|
|
485
|
+
dailyRetentionDays:
|
|
486
|
+
response.retentionConfig?.dailyRetentionDays ??
|
|
487
|
+
DEFAULT_RETENTION_CONFIG.dailyRetentionDays,
|
|
488
|
+
isCustom: !!response.retentionConfig,
|
|
489
|
+
loading: false,
|
|
490
|
+
},
|
|
491
|
+
}));
|
|
492
|
+
} catch {
|
|
493
|
+
setRetentionData((prev) => ({
|
|
494
|
+
...prev,
|
|
495
|
+
[configId]: { ...prev[configId], loading: false },
|
|
496
|
+
}));
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const handleSaveRetention = async (configId: string) => {
|
|
501
|
+
const data = retentionData[configId];
|
|
502
|
+
if (!data) return;
|
|
503
|
+
|
|
504
|
+
setSaving(true);
|
|
505
|
+
try {
|
|
506
|
+
await api.updateRetentionConfig({
|
|
507
|
+
systemId,
|
|
508
|
+
configurationId: configId,
|
|
509
|
+
retentionConfig: {
|
|
510
|
+
rawRetentionDays: data.rawRetentionDays,
|
|
511
|
+
hourlyRetentionDays: data.hourlyRetentionDays,
|
|
512
|
+
dailyRetentionDays: data.dailyRetentionDays,
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
toast.success("Retention settings saved");
|
|
516
|
+
setSelectedPanel(undefined);
|
|
517
|
+
} catch (error) {
|
|
518
|
+
const message = error instanceof Error ? error.message : "Failed to save";
|
|
519
|
+
toast.error(message);
|
|
520
|
+
} finally {
|
|
521
|
+
setSaving(false);
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const handleResetRetention = async (configId: string) => {
|
|
526
|
+
setSaving(true);
|
|
527
|
+
try {
|
|
528
|
+
await api.updateRetentionConfig({
|
|
529
|
+
systemId,
|
|
530
|
+
configurationId: configId,
|
|
531
|
+
// eslint-disable-next-line unicorn/no-null -- RPC contract uses nullable()
|
|
532
|
+
retentionConfig: null,
|
|
533
|
+
});
|
|
534
|
+
setRetentionData((prev) => ({
|
|
535
|
+
...prev,
|
|
536
|
+
[configId]: {
|
|
537
|
+
rawRetentionDays: DEFAULT_RETENTION_CONFIG.rawRetentionDays,
|
|
538
|
+
hourlyRetentionDays: DEFAULT_RETENTION_CONFIG.hourlyRetentionDays,
|
|
539
|
+
dailyRetentionDays: DEFAULT_RETENTION_CONFIG.dailyRetentionDays,
|
|
540
|
+
isCustom: false,
|
|
541
|
+
loading: false,
|
|
542
|
+
},
|
|
543
|
+
}));
|
|
544
|
+
toast.success("Reset to defaults");
|
|
545
|
+
} catch (error) {
|
|
546
|
+
const message =
|
|
547
|
+
error instanceof Error ? error.message : "Failed to reset";
|
|
548
|
+
toast.error(message);
|
|
549
|
+
} finally {
|
|
550
|
+
setSaving(false);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const updateRetentionField = (
|
|
555
|
+
configId: string,
|
|
556
|
+
field: string,
|
|
557
|
+
value: number
|
|
558
|
+
) => {
|
|
559
|
+
setRetentionData((prev) => ({
|
|
560
|
+
...prev,
|
|
561
|
+
[configId]: { ...prev[configId], [field]: value, isCustom: true },
|
|
562
|
+
}));
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const renderRetentionEditor = (configId: string) => {
|
|
566
|
+
const data = retentionData[configId];
|
|
567
|
+
|
|
568
|
+
// Trigger load if not loaded
|
|
569
|
+
if (!data) {
|
|
570
|
+
loadRetentionConfig(configId);
|
|
571
|
+
return (
|
|
572
|
+
<div className="mt-4 flex justify-center py-4">
|
|
573
|
+
<LoadingSpinner />
|
|
574
|
+
</div>
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (data.loading) {
|
|
579
|
+
return (
|
|
580
|
+
<div className="mt-4 flex justify-center py-4">
|
|
581
|
+
<LoadingSpinner />
|
|
582
|
+
</div>
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Validation
|
|
587
|
+
const isValidHierarchy =
|
|
588
|
+
data.rawRetentionDays < data.hourlyRetentionDays &&
|
|
589
|
+
data.hourlyRetentionDays < data.dailyRetentionDays;
|
|
590
|
+
|
|
591
|
+
return (
|
|
592
|
+
<div className="mt-4 space-y-3">
|
|
593
|
+
{!data.isCustom && (
|
|
594
|
+
<div className="rounded-md bg-muted p-3 text-sm text-muted-foreground">
|
|
595
|
+
Using default retention settings. Customize below to override.
|
|
596
|
+
</div>
|
|
597
|
+
)}
|
|
598
|
+
|
|
599
|
+
{!isValidHierarchy && (
|
|
600
|
+
<div className="rounded-md bg-destructive/10 border border-destructive/30 p-3 text-sm text-destructive">
|
|
601
|
+
Retention periods must increase: Raw < Hourly < Daily
|
|
602
|
+
</div>
|
|
603
|
+
)}
|
|
604
|
+
|
|
605
|
+
{/* Raw Data */}
|
|
606
|
+
<div className="p-3 rounded-lg border bg-muted/30">
|
|
607
|
+
<div className="flex items-center justify-between">
|
|
608
|
+
<div>
|
|
609
|
+
<span className="text-sm font-medium">Raw Data Retention</span>
|
|
610
|
+
<p className="text-xs text-muted-foreground">
|
|
611
|
+
Individual run data before hourly aggregation
|
|
612
|
+
</p>
|
|
613
|
+
</div>
|
|
614
|
+
<div className="flex items-center gap-2">
|
|
615
|
+
<Input
|
|
616
|
+
type="number"
|
|
617
|
+
min={1}
|
|
618
|
+
max={30}
|
|
619
|
+
value={data.rawRetentionDays}
|
|
620
|
+
onChange={(e) =>
|
|
621
|
+
updateRetentionField(
|
|
622
|
+
configId,
|
|
623
|
+
"rawRetentionDays",
|
|
624
|
+
Number(e.target.value)
|
|
625
|
+
)
|
|
626
|
+
}
|
|
627
|
+
className="h-8 w-20 text-center"
|
|
628
|
+
/>
|
|
629
|
+
<span className="text-sm text-muted-foreground w-10">days</span>
|
|
630
|
+
</div>
|
|
631
|
+
</div>
|
|
632
|
+
</div>
|
|
633
|
+
|
|
634
|
+
{/* Hourly Aggregates */}
|
|
635
|
+
<div className="p-3 rounded-lg border bg-muted/30">
|
|
636
|
+
<div className="flex items-center justify-between">
|
|
637
|
+
<div>
|
|
638
|
+
<span className="text-sm font-medium">Hourly Aggregates</span>
|
|
639
|
+
<p className="text-xs text-muted-foreground">
|
|
640
|
+
Hourly stats before daily rollup
|
|
641
|
+
</p>
|
|
642
|
+
</div>
|
|
643
|
+
<div className="flex items-center gap-2">
|
|
644
|
+
<Input
|
|
645
|
+
type="number"
|
|
646
|
+
min={7}
|
|
647
|
+
max={365}
|
|
648
|
+
value={data.hourlyRetentionDays}
|
|
649
|
+
onChange={(e) =>
|
|
650
|
+
updateRetentionField(
|
|
651
|
+
configId,
|
|
652
|
+
"hourlyRetentionDays",
|
|
653
|
+
Number(e.target.value)
|
|
654
|
+
)
|
|
655
|
+
}
|
|
656
|
+
className="h-8 w-20 text-center"
|
|
657
|
+
/>
|
|
658
|
+
<span className="text-sm text-muted-foreground w-10">days</span>
|
|
659
|
+
</div>
|
|
660
|
+
</div>
|
|
661
|
+
</div>
|
|
662
|
+
|
|
663
|
+
{/* Daily Aggregates */}
|
|
664
|
+
<div className="p-3 rounded-lg border bg-muted/30">
|
|
665
|
+
<div className="flex items-center justify-between">
|
|
666
|
+
<div>
|
|
667
|
+
<span className="text-sm font-medium">Daily Aggregates</span>
|
|
668
|
+
<p className="text-xs text-muted-foreground">
|
|
669
|
+
Long-term storage before deletion
|
|
670
|
+
</p>
|
|
671
|
+
</div>
|
|
672
|
+
<div className="flex items-center gap-2">
|
|
673
|
+
<Input
|
|
674
|
+
type="number"
|
|
675
|
+
min={30}
|
|
676
|
+
max={1095}
|
|
677
|
+
value={data.dailyRetentionDays}
|
|
678
|
+
onChange={(e) =>
|
|
679
|
+
updateRetentionField(
|
|
680
|
+
configId,
|
|
681
|
+
"dailyRetentionDays",
|
|
682
|
+
Number(e.target.value)
|
|
683
|
+
)
|
|
684
|
+
}
|
|
685
|
+
className="h-8 w-20 text-center"
|
|
686
|
+
/>
|
|
687
|
+
<span className="text-sm text-muted-foreground w-10">days</span>
|
|
688
|
+
</div>
|
|
689
|
+
</div>
|
|
690
|
+
</div>
|
|
691
|
+
|
|
692
|
+
{/* Action Buttons */}
|
|
693
|
+
<div className="flex justify-between pt-2 border-t">
|
|
694
|
+
<Button
|
|
695
|
+
variant="ghost"
|
|
696
|
+
size="sm"
|
|
697
|
+
onClick={() => handleResetRetention(configId)}
|
|
698
|
+
disabled={saving || !data.isCustom}
|
|
699
|
+
>
|
|
700
|
+
Reset to Defaults
|
|
701
|
+
</Button>
|
|
702
|
+
<div className="flex gap-2">
|
|
703
|
+
<Button
|
|
704
|
+
variant="outline"
|
|
705
|
+
size="sm"
|
|
706
|
+
onClick={() => setSelectedPanel(undefined)}
|
|
707
|
+
>
|
|
708
|
+
Cancel
|
|
709
|
+
</Button>
|
|
710
|
+
<Button
|
|
711
|
+
size="sm"
|
|
712
|
+
onClick={() => handleSaveRetention(configId)}
|
|
713
|
+
disabled={saving || !isValidHierarchy}
|
|
714
|
+
>
|
|
715
|
+
{saving ? "Saving..." : "Save Retention"}
|
|
716
|
+
</Button>
|
|
717
|
+
</div>
|
|
718
|
+
</div>
|
|
719
|
+
</div>
|
|
720
|
+
);
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
return (
|
|
724
|
+
<>
|
|
725
|
+
<Button
|
|
726
|
+
variant="outline"
|
|
727
|
+
size="sm"
|
|
728
|
+
onClick={() => setIsOpen(true)}
|
|
729
|
+
className="h-8 gap-1.5 border-dashed border-input hover:border-primary/30 hover:bg-primary/5"
|
|
730
|
+
>
|
|
731
|
+
<Activity className="h-3.5 w-3.5 text-primary" />
|
|
732
|
+
<span className="text-xs font-medium">Health Checks</span>
|
|
733
|
+
{assignedIds.length > 0 && (
|
|
734
|
+
<span className="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary/10 text-[10px] font-bold text-primary">
|
|
735
|
+
{assignedIds.length}
|
|
736
|
+
</span>
|
|
737
|
+
)}
|
|
738
|
+
</Button>
|
|
739
|
+
|
|
740
|
+
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
741
|
+
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
|
|
742
|
+
<DialogHeader>
|
|
743
|
+
<DialogTitle>Health Check Assignments</DialogTitle>
|
|
744
|
+
</DialogHeader>
|
|
745
|
+
|
|
746
|
+
{loading ? (
|
|
747
|
+
<div className="flex justify-center py-8">
|
|
748
|
+
<LoadingSpinner />
|
|
749
|
+
</div>
|
|
750
|
+
) : configs.length === 0 ? (
|
|
751
|
+
<p className="text-sm text-muted-foreground text-center py-4 italic">
|
|
752
|
+
No health checks configured.
|
|
753
|
+
</p>
|
|
754
|
+
) : (
|
|
755
|
+
<div className="space-y-2">
|
|
756
|
+
{configs.map((config) => {
|
|
757
|
+
const assoc = associations.find(
|
|
758
|
+
(a) => a.configurationId === config.id
|
|
759
|
+
);
|
|
760
|
+
const isAssigned = !!assoc;
|
|
761
|
+
const isExpanded =
|
|
762
|
+
selectedPanel?.configId === config.id &&
|
|
763
|
+
selectedPanel?.panel === "thresholds";
|
|
764
|
+
const isRetentionExpanded =
|
|
765
|
+
selectedPanel?.configId === config.id &&
|
|
766
|
+
selectedPanel?.panel === "retention";
|
|
767
|
+
|
|
768
|
+
return (
|
|
769
|
+
<div
|
|
770
|
+
key={config.id}
|
|
771
|
+
className="rounded-lg border bg-card p-3"
|
|
772
|
+
>
|
|
773
|
+
<div className="flex items-center justify-between">
|
|
774
|
+
<div className="flex items-center gap-3">
|
|
775
|
+
<Checkbox
|
|
776
|
+
checked={isAssigned}
|
|
777
|
+
onCheckedChange={() =>
|
|
778
|
+
handleToggleAssignment(config.id, isAssigned)
|
|
779
|
+
}
|
|
780
|
+
disabled={saving}
|
|
781
|
+
/>
|
|
782
|
+
<div>
|
|
783
|
+
<div className="font-medium text-sm">
|
|
784
|
+
{config.name}
|
|
785
|
+
</div>
|
|
786
|
+
<div className="text-xs text-muted-foreground">
|
|
787
|
+
{config.strategyId} • every {config.intervalSeconds}
|
|
788
|
+
s
|
|
789
|
+
</div>
|
|
790
|
+
</div>
|
|
791
|
+
</div>
|
|
792
|
+
{isAssigned && (
|
|
793
|
+
<div className="flex items-center gap-1">
|
|
794
|
+
{canManage && (
|
|
795
|
+
<Button
|
|
796
|
+
variant="ghost"
|
|
797
|
+
size="sm"
|
|
798
|
+
asChild
|
|
799
|
+
className="h-7 px-2"
|
|
800
|
+
>
|
|
801
|
+
<Link
|
|
802
|
+
to={resolveRoute(
|
|
803
|
+
healthcheckRoutes.routes.historyDetail,
|
|
804
|
+
{
|
|
805
|
+
systemId,
|
|
806
|
+
configurationId: config.id,
|
|
807
|
+
}
|
|
808
|
+
)}
|
|
809
|
+
>
|
|
810
|
+
<History className="h-4 w-4" />
|
|
811
|
+
</Link>
|
|
812
|
+
</Button>
|
|
813
|
+
)}
|
|
814
|
+
<Button
|
|
815
|
+
variant="ghost"
|
|
816
|
+
size="sm"
|
|
817
|
+
onClick={() =>
|
|
818
|
+
setSelectedPanel(
|
|
819
|
+
isExpanded
|
|
820
|
+
? undefined
|
|
821
|
+
: { configId: config.id, panel: "thresholds" }
|
|
822
|
+
)
|
|
823
|
+
}
|
|
824
|
+
className="h-7 px-2"
|
|
825
|
+
>
|
|
826
|
+
<Settings2 className="h-4 w-4" />
|
|
827
|
+
<span className="ml-1 text-xs">Thresholds</span>
|
|
828
|
+
</Button>
|
|
829
|
+
<Button
|
|
830
|
+
variant="ghost"
|
|
831
|
+
size="sm"
|
|
832
|
+
onClick={() =>
|
|
833
|
+
setSelectedPanel(
|
|
834
|
+
isRetentionExpanded
|
|
835
|
+
? undefined
|
|
836
|
+
: { configId: config.id, panel: "retention" }
|
|
837
|
+
)
|
|
838
|
+
}
|
|
839
|
+
className="h-7 px-2"
|
|
840
|
+
>
|
|
841
|
+
<Database className="h-4 w-4" />
|
|
842
|
+
<span className="ml-1 text-xs">Retention</span>
|
|
843
|
+
</Button>
|
|
844
|
+
</div>
|
|
845
|
+
)}
|
|
846
|
+
</div>
|
|
847
|
+
{isAssigned &&
|
|
848
|
+
isExpanded &&
|
|
849
|
+
assoc &&
|
|
850
|
+
renderThresholdEditor(assoc)}
|
|
851
|
+
{isAssigned &&
|
|
852
|
+
isRetentionExpanded &&
|
|
853
|
+
renderRetentionEditor(config.id)}
|
|
854
|
+
</div>
|
|
855
|
+
);
|
|
856
|
+
})}
|
|
857
|
+
</div>
|
|
858
|
+
)}
|
|
859
|
+
|
|
860
|
+
<DialogFooter>
|
|
861
|
+
<Button variant="outline" onClick={() => setIsOpen(false)}>
|
|
862
|
+
Close
|
|
863
|
+
</Button>
|
|
864
|
+
</DialogFooter>
|
|
865
|
+
</DialogContent>
|
|
866
|
+
</Dialog>
|
|
867
|
+
</>
|
|
868
|
+
);
|
|
869
|
+
};
|