@checkstack/healthcheck-frontend 0.12.1 → 0.13.1

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