@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.
@@ -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 &lt; Hourly &lt; 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
+ };