@checkstack/healthcheck-frontend 0.11.8 → 0.12.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.
@@ -0,0 +1,185 @@
1
+ import React, { useMemo } from "react";
2
+ import type {
3
+ CollectorConfigEntry,
4
+ CollectorDto,
5
+ } from "@checkstack/healthcheck-common";
6
+ import { Plus, Settings, Shield, ChevronRight } from "lucide-react";
7
+ import { isBuiltInCollector } from "../../hooks/useCollectors";
8
+ import type { ValidationIssue } from "./IDEStatusBar";
9
+ import { Badge } from "@checkstack/ui";
10
+
11
+ // =============================================================================
12
+ // TYPES
13
+ // =============================================================================
14
+
15
+ export type TreeNodeId =
16
+ | "general"
17
+ | "access"
18
+ | "collector-picker"
19
+ | `collector:${string}`;
20
+
21
+ interface EditorTreeProps {
22
+ collectors: CollectorConfigEntry[];
23
+ availableCollectors: CollectorDto[];
24
+ selectedNode: TreeNodeId;
25
+ onSelectNode: (nodeId: TreeNodeId) => void;
26
+ onAddCollector: (collectorId: string) => void;
27
+ validationIssues: ValidationIssue[];
28
+ strategyId: string;
29
+ }
30
+
31
+ // =============================================================================
32
+ // VALIDATION INDICATOR
33
+ // =============================================================================
34
+
35
+ function ValidationDot({ nodeId, issues }: { nodeId: string; issues: ValidationIssue[] }) {
36
+ const nodeIssues = issues.filter((i) => i.nodeId === nodeId);
37
+ if (nodeIssues.length === 0) return;
38
+
39
+ return (
40
+ <span className="ml-auto flex h-2 w-2 rounded-full bg-destructive shrink-0" />
41
+ );
42
+ }
43
+
44
+ // =============================================================================
45
+ // TREE NODE
46
+ // =============================================================================
47
+
48
+ function TreeNode({
49
+ nodeId,
50
+ label,
51
+ icon: Icon,
52
+ selected,
53
+ onClick,
54
+ issues,
55
+ indent = false,
56
+ badge,
57
+ }: {
58
+ nodeId: string;
59
+ label: string;
60
+ icon: React.ElementType;
61
+ selected: boolean;
62
+ onClick: () => void;
63
+ issues: ValidationIssue[];
64
+ indent?: boolean;
65
+ badge?: string;
66
+ }) {
67
+ return (
68
+ <button
69
+ type="button"
70
+ onClick={onClick}
71
+ className={`flex items-center gap-2 w-full px-3 py-2 text-sm text-left transition-colors ${
72
+ indent ? "pl-7" : ""
73
+ } ${
74
+ selected
75
+ ? "bg-primary/10 text-primary border-l-2 border-primary"
76
+ : "hover:bg-muted/50 border-l-2 border-transparent"
77
+ }`}
78
+ >
79
+ <Icon className="h-4 w-4 shrink-0 opacity-60" />
80
+ <span className="truncate flex-1">{label}</span>
81
+ {badge && (
82
+ <Badge variant="secondary" className="text-[10px] shrink-0">
83
+ {badge}
84
+ </Badge>
85
+ )}
86
+ <ValidationDot nodeId={nodeId} issues={issues} />
87
+ </button>
88
+ );
89
+ }
90
+
91
+ // =============================================================================
92
+ // EDITOR TREE
93
+ // =============================================================================
94
+
95
+ export const EditorTree: React.FC<EditorTreeProps> = ({
96
+ collectors,
97
+ availableCollectors,
98
+ selectedNode,
99
+ onSelectNode,
100
+ validationIssues,
101
+ strategyId,
102
+ }) => {
103
+ // Check if there are addable collectors remaining
104
+ const hasAddableCollectors = useMemo(() => {
105
+ const configuredIds = new Set(collectors.map((c) => c.collectorId));
106
+ return availableCollectors.some(
107
+ (c) => !configuredIds.has(c.id) || c.allowMultiple,
108
+ );
109
+ }, [collectors, availableCollectors]);
110
+
111
+ return (
112
+ <div className="py-2">
113
+ {/* General */}
114
+ <TreeNode
115
+ nodeId="general"
116
+ label="General"
117
+ icon={Settings}
118
+ selected={selectedNode === "general"}
119
+ onClick={() => onSelectNode("general")}
120
+ issues={validationIssues}
121
+ />
122
+
123
+ {/* Collectors Section Header */}
124
+ <div className="px-3 pt-4 pb-1">
125
+ <span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
126
+ Check Items
127
+ </span>
128
+ </div>
129
+
130
+ {/* Configured Collectors */}
131
+ {collectors.map((entry) => {
132
+ const collector = availableCollectors.find(
133
+ (c) => c.id === entry.collectorId,
134
+ );
135
+ const builtIn = isBuiltInCollector(entry.collectorId, strategyId);
136
+
137
+ return (
138
+ <TreeNode
139
+ key={entry.id}
140
+ nodeId={`collector:${entry.id}`}
141
+ label={collector?.displayName ?? entry.collectorId}
142
+ icon={ChevronRight}
143
+ selected={selectedNode === `collector:${entry.id}`}
144
+ onClick={() => onSelectNode(`collector:${entry.id}`)}
145
+ issues={validationIssues}
146
+ indent
147
+ badge={builtIn ? "Built-in" : undefined}
148
+ />
149
+ );
150
+ })}
151
+
152
+ {/* Add Collector Button */}
153
+ {hasAddableCollectors && (
154
+ <button
155
+ type="button"
156
+ onClick={() => onSelectNode("collector-picker")}
157
+ className={`flex items-center gap-2 w-full px-3 py-2 pl-7 text-sm text-left transition-colors text-muted-foreground hover:text-foreground hover:bg-muted/50 border-l-2 ${
158
+ selectedNode === "collector-picker"
159
+ ? "border-primary bg-primary/10 text-primary"
160
+ : "border-transparent"
161
+ }`}
162
+ >
163
+ <Plus className="h-4 w-4 shrink-0" />
164
+ <span className="truncate">Add check item...</span>
165
+ </button>
166
+ )}
167
+
168
+ {/* Access Control */}
169
+ <div className="px-3 pt-4 pb-1">
170
+ <span className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
171
+ Permissions
172
+ </span>
173
+ </div>
174
+
175
+ <TreeNode
176
+ nodeId="access"
177
+ label="Access Control"
178
+ icon={Shield}
179
+ selected={selectedNode === "access"}
180
+ onClick={() => onSelectNode("access")}
181
+ issues={validationIssues}
182
+ />
183
+ </div>
184
+ );
185
+ };
@@ -0,0 +1,98 @@
1
+ import React from "react";
2
+ import type { HealthCheckStrategyDto } from "@checkstack/healthcheck-common";
3
+ import { Input, Label, DynamicForm } from "@checkstack/ui";
4
+ import { AlertTriangle } from "lucide-react";
5
+
6
+ interface GeneralSectionProps {
7
+ name: string;
8
+ intervalSeconds: number;
9
+ strategyConfig: Record<string, unknown>;
10
+ strategy: HealthCheckStrategyDto | undefined;
11
+ onNameChange: (name: string) => void;
12
+ onIntervalChange: (interval: number) => void;
13
+ onStrategyConfigChange: (config: Record<string, unknown>) => void;
14
+ onStrategyConfigValidChange: (isValid: boolean) => void;
15
+ }
16
+
17
+ export const GeneralSection: React.FC<GeneralSectionProps> = ({
18
+ name,
19
+ intervalSeconds,
20
+ strategyConfig,
21
+ strategy,
22
+ onNameChange,
23
+ onIntervalChange,
24
+ onStrategyConfigChange,
25
+ onStrategyConfigValidChange,
26
+ }) => {
27
+ return (
28
+ <div className="space-y-6">
29
+ <div>
30
+ <h2 className="text-lg font-semibold">General</h2>
31
+ <p className="text-sm text-muted-foreground">
32
+ Basic configuration for this health check.
33
+ </p>
34
+ </div>
35
+
36
+ {/* Strategy Display */}
37
+ {strategy && (
38
+ <div className="flex items-center gap-2 rounded-md border border-border/50 bg-muted/30 px-3 py-2">
39
+ <span className="text-sm font-medium">{strategy.displayName}</span>
40
+ {strategy.description && (
41
+ <span className="text-xs text-muted-foreground">
42
+ — {strategy.description}
43
+ </span>
44
+ )}
45
+ </div>
46
+ )}
47
+
48
+ {/* Name */}
49
+ <div className="space-y-2">
50
+ <Label htmlFor="hc-name">Name</Label>
51
+ <Input
52
+ id="hc-name"
53
+ value={name}
54
+ onChange={(e) => onNameChange(e.target.value)}
55
+ placeholder="e.g. Production API Health"
56
+ />
57
+ </div>
58
+
59
+ {/* Interval */}
60
+ <div className="space-y-2">
61
+ <Label htmlFor="hc-interval">Interval (seconds)</Label>
62
+ <Input
63
+ id="hc-interval"
64
+ type="number"
65
+ min={1}
66
+ value={intervalSeconds}
67
+ onChange={(e) => onIntervalChange(Number(e.target.value))}
68
+ />
69
+ {intervalSeconds > 0 && intervalSeconds < 60 && (
70
+ <div className="flex items-center gap-1.5 text-xs text-amber-500">
71
+ <AlertTriangle className="h-3 w-3" />
72
+ <span>
73
+ Sub-minute intervals may cause high load on monitored services.
74
+ </span>
75
+ </div>
76
+ )}
77
+ </div>
78
+
79
+ {/* Strategy Config */}
80
+ {strategy?.configSchema && (
81
+ <div className="space-y-3 pt-2 border-t">
82
+ <div>
83
+ <h3 className="text-sm font-semibold">Strategy Configuration</h3>
84
+ <p className="text-xs text-muted-foreground">
85
+ Settings specific to {strategy.displayName}.
86
+ </p>
87
+ </div>
88
+ <DynamicForm
89
+ schema={strategy.configSchema}
90
+ value={strategyConfig}
91
+ onChange={onStrategyConfigChange}
92
+ onValidChange={onStrategyConfigValidChange}
93
+ />
94
+ </div>
95
+ )}
96
+ </div>
97
+ );
98
+ };
@@ -0,0 +1,58 @@
1
+ import React from "react";
2
+ import type { TreeNodeId } from "./EditorTree";
3
+ import { AlertCircle, CheckCircle2 } from "lucide-react";
4
+
5
+ // =============================================================================
6
+ // TYPES
7
+ // =============================================================================
8
+
9
+ export interface ValidationIssue {
10
+ nodeId: TreeNodeId;
11
+ message: string;
12
+ }
13
+
14
+ interface IDEStatusBarProps {
15
+ issues: ValidationIssue[];
16
+ onIssueClick: (nodeId: TreeNodeId) => void;
17
+ }
18
+
19
+ // =============================================================================
20
+ // STATUS BAR
21
+ // =============================================================================
22
+
23
+ export const IDEStatusBar: React.FC<IDEStatusBarProps> = ({
24
+ issues,
25
+ onIssueClick,
26
+ }) => {
27
+ if (issues.length === 0) {
28
+ return (
29
+ <div className="flex items-center gap-2 px-4 py-2 mt-2 rounded-md border bg-card text-xs text-muted-foreground">
30
+ <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
31
+ <span>No issues found</span>
32
+ </div>
33
+ );
34
+ }
35
+
36
+ return (
37
+ <div className="flex items-center gap-3 px-4 py-2 mt-2 rounded-md border bg-card text-xs">
38
+ <div className="flex items-center gap-1.5 text-destructive shrink-0">
39
+ <AlertCircle className="h-3.5 w-3.5" />
40
+ <span className="font-medium">
41
+ {issues.length} {issues.length === 1 ? "issue" : "issues"}
42
+ </span>
43
+ </div>
44
+ <div className="flex items-center gap-2 overflow-x-auto">
45
+ {issues.map((issue, i) => (
46
+ <button
47
+ key={`${issue.nodeId}-${i}`}
48
+ type="button"
49
+ onClick={() => onIssueClick(issue.nodeId)}
50
+ className="text-muted-foreground hover:text-foreground transition-colors whitespace-nowrap underline-offset-2 hover:underline"
51
+ >
52
+ {issue.message}
53
+ </button>
54
+ ))}
55
+ </div>
56
+ </div>
57
+ );
58
+ };
@@ -26,7 +26,7 @@ export function useCollectors(strategyId: string): UseCollectorsResult {
26
26
  );
27
27
 
28
28
  const collectors = data ?? [];
29
- const error = queryError instanceof Error ? queryError : undefined;
29
+ const error = queryError ?? undefined;
30
30
 
31
31
  return {
32
32
  collectors,
package/src/index.tsx CHANGED
@@ -4,6 +4,8 @@ import {
4
4
  UserMenuItemsSlot,
5
5
  } from "@checkstack/frontend-api";
6
6
  import { HealthCheckConfigPage } from "./pages/HealthCheckConfigPage";
7
+ import { StrategyPickerPage } from "./pages/StrategyPickerPage";
8
+ import { HealthCheckIDEPage } from "./pages/HealthCheckIDEPage";
7
9
  import { HealthCheckHistoryPage } from "./pages/HealthCheckHistoryPage";
8
10
  import { HealthCheckHistoryDetailPage } from "./pages/HealthCheckHistoryDetailPage";
9
11
  import { HealthCheckMenuItems } from "./components/HealthCheckMenuItems";
@@ -43,6 +45,18 @@ export default createFrontendPlugin({
43
45
  title: "Health Checks",
44
46
  accessRule: healthCheckAccess.configuration.manage,
45
47
  },
48
+ {
49
+ route: healthcheckRoutes.routes.create,
50
+ element: <StrategyPickerPage />,
51
+ title: "Create Health Check",
52
+ accessRule: healthCheckAccess.configuration.manage,
53
+ },
54
+ {
55
+ route: healthcheckRoutes.routes.edit,
56
+ element: <HealthCheckIDEPage />,
57
+ title: "Edit Health Check",
58
+ accessRule: healthCheckAccess.configuration.manage,
59
+ },
46
60
  {
47
61
  route: healthcheckRoutes.routes.history,
48
62
  element: <HealthCheckHistoryPage />,
@@ -1,5 +1,5 @@
1
- import { useEffect, useState } from "react";
2
- import { useSearchParams } from "react-router-dom";
1
+ import { useEffect } from "react";
2
+ import { useSearchParams, useNavigate } from "react-router-dom";
3
3
  import {
4
4
  usePluginClient,
5
5
  wrapInSuspense,
@@ -8,13 +8,11 @@ import {
8
8
  } from "@checkstack/frontend-api";
9
9
  import { HealthCheckApi } from "../api";
10
10
  import {
11
- HealthCheckConfiguration,
12
- CreateHealthCheckConfiguration,
11
+ type HealthCheckConfiguration,
13
12
  healthcheckRoutes,
14
13
  healthCheckAccess,
15
14
  } from "@checkstack/healthcheck-common";
16
15
  import { HealthCheckList } from "../components/HealthCheckList";
17
- import { HealthCheckEditor } from "../components/HealthCheckEditor";
18
16
  import {
19
17
  Button,
20
18
  ConfirmationModal,
@@ -23,12 +21,14 @@ import {
23
21
  } from "@checkstack/ui";
24
22
  import { Plus, History, Activity } from "lucide-react";
25
23
  import { Link } from "react-router-dom";
26
- import { resolveRoute } from "@checkstack/common";
24
+ import { resolveRoute, extractErrorMessage} from "@checkstack/common";
25
+ import { useState } from "react";
27
26
 
28
27
  const HealthCheckConfigPageContent = () => {
29
28
  const healthCheckClient = usePluginClient(HealthCheckApi);
30
29
  const accessApi = useApi(accessApiRef);
31
30
  const toast = useToast();
31
+ const navigate = useNavigate();
32
32
  const [searchParams, setSearchParams] = useSearchParams();
33
33
  const { allowed: canRead, loading: accessLoading } = accessApi.useAccess(
34
34
  healthCheckAccess.configuration.read,
@@ -37,11 +37,6 @@ const HealthCheckConfigPageContent = () => {
37
37
  healthCheckAccess.configuration.manage,
38
38
  );
39
39
 
40
- const [isEditorOpen, setIsEditorOpen] = useState(false);
41
- const [editingConfig, setEditingConfig] = useState<
42
- HealthCheckConfiguration | undefined
43
- >();
44
-
45
40
  // Delete modal state
46
41
  const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
47
42
  const [idToDelete, setIdToDelete] = useState<string | undefined>();
@@ -60,35 +55,14 @@ const HealthCheckConfigPageContent = () => {
60
55
  // Handle ?action=create URL parameter (from command palette)
61
56
  useEffect(() => {
62
57
  if (searchParams.get("action") === "create" && canManage) {
63
- setEditingConfig(undefined);
64
- setIsEditorOpen(true);
65
- // Clear the URL param after opening
58
+ // Clear the URL param and navigate to the create flow
66
59
  searchParams.delete("action");
67
60
  setSearchParams(searchParams, { replace: true });
61
+ navigate(resolveRoute(healthcheckRoutes.routes.create));
68
62
  }
69
- }, [searchParams, canManage, setSearchParams]);
63
+ }, [searchParams, canManage, setSearchParams, navigate]);
70
64
 
71
65
  // Mutations
72
- const createMutation = healthCheckClient.createConfiguration.useMutation({
73
- onSuccess: () => {
74
- setIsEditorOpen(false);
75
- void refetchConfigurations();
76
- },
77
- onError: (error) => {
78
- toast.error(error instanceof Error ? error.message : "Failed to create");
79
- },
80
- });
81
-
82
- const updateMutation = healthCheckClient.updateConfiguration.useMutation({
83
- onSuccess: () => {
84
- setIsEditorOpen(false);
85
- void refetchConfigurations();
86
- },
87
- onError: (error) => {
88
- toast.error(error instanceof Error ? error.message : "Failed to update");
89
- },
90
- });
91
-
92
66
  const deleteMutation = healthCheckClient.deleteConfiguration.useMutation({
93
67
  onSuccess: () => {
94
68
  setIsDeleteModalOpen(false);
@@ -96,7 +70,7 @@ const HealthCheckConfigPageContent = () => {
96
70
  void refetchConfigurations();
97
71
  },
98
72
  onError: (error) => {
99
- toast.error(error instanceof Error ? error.message : "Failed to delete");
73
+ toast.error(extractErrorMessage(error, "Failed to delete"));
100
74
  },
101
75
  });
102
76
 
@@ -105,7 +79,7 @@ const HealthCheckConfigPageContent = () => {
105
79
  void refetchConfigurations();
106
80
  },
107
81
  onError: (error) => {
108
- toast.error(error instanceof Error ? error.message : "Failed to pause");
82
+ toast.error(extractErrorMessage(error, "Failed to pause"));
109
83
  },
110
84
  });
111
85
 
@@ -114,18 +88,18 @@ const HealthCheckConfigPageContent = () => {
114
88
  void refetchConfigurations();
115
89
  },
116
90
  onError: (error) => {
117
- toast.error(error instanceof Error ? error.message : "Failed to resume");
91
+ toast.error(extractErrorMessage(error, "Failed to resume"));
118
92
  },
119
93
  });
120
94
 
121
95
  const handleCreate = () => {
122
- setEditingConfig(undefined);
123
- setIsEditorOpen(true);
96
+ navigate(resolveRoute(healthcheckRoutes.routes.create));
124
97
  };
125
98
 
126
99
  const handleEdit = (config: HealthCheckConfiguration) => {
127
- setEditingConfig(config);
128
- setIsEditorOpen(true);
100
+ navigate(
101
+ resolveRoute(healthcheckRoutes.routes.edit, { configId: config.id }),
102
+ );
129
103
  };
130
104
 
131
105
  const handleDelete = (id: string) => {
@@ -138,19 +112,6 @@ const HealthCheckConfigPageContent = () => {
138
112
  deleteMutation.mutate(idToDelete);
139
113
  };
140
114
 
141
- const handleSave = async (data: CreateHealthCheckConfiguration) => {
142
- if (editingConfig) {
143
- updateMutation.mutate({ id: editingConfig.id, body: data });
144
- } else {
145
- createMutation.mutate(data);
146
- }
147
- };
148
-
149
- const handleEditorClose = () => {
150
- setIsEditorOpen(false);
151
- setEditingConfig(undefined);
152
- };
153
-
154
115
  return (
155
116
  <PageLayout
156
117
  title="Health Checks"
@@ -183,14 +144,6 @@ const HealthCheckConfigPageContent = () => {
183
144
  canManage={canManage}
184
145
  />
185
146
 
186
- <HealthCheckEditor
187
- open={isEditorOpen}
188
- strategies={strategies}
189
- initialData={editingConfig}
190
- onSave={handleSave}
191
- onCancel={handleEditorClose}
192
- />
193
-
194
147
  <ConfirmationModal
195
148
  isOpen={isDeleteModalOpen}
196
149
  onClose={() => setIsDeleteModalOpen(false)}