@checkstack/healthcheck-frontend 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,14 +12,14 @@ interface AggregatedDataBannerProps {
12
12
  */
13
13
  function formatDuration(seconds: number): string {
14
14
  if (seconds < 60) {
15
- return `${seconds}s`;
15
+ return `~${seconds}s`;
16
16
  }
17
17
  if (seconds < 3600) {
18
18
  const mins = Math.round(seconds / 60);
19
- return `${mins}min`;
19
+ return `~${mins}min`;
20
20
  }
21
21
  const hours = Math.round(seconds / 3600);
22
- return `${hours}h`;
22
+ return `~${hours}h`;
23
23
  }
24
24
 
25
25
  /**
@@ -7,6 +7,7 @@ import {
7
7
  Card,
8
8
  CardContent,
9
9
  CardHeader,
10
+ CardHeaderRow,
10
11
  CardTitle,
11
12
  Label,
12
13
  Select,
@@ -157,62 +158,68 @@ export const CollectorList: React.FC<CollectorListProps> = ({
157
158
 
158
159
  return (
159
160
  <Card>
160
- <CardHeader className="flex flex-row items-center justify-between">
161
- <CardTitle className="text-base">Check Items</CardTitle>
162
- {addableCollectors.length > 0 && (
163
- <Select value="" onValueChange={handleAdd}>
164
- <SelectTrigger className="w-[200px]">
165
- <Plus className="h-4 w-4 mr-2" />
166
- <SelectValue placeholder="Add collector..." />
167
- </SelectTrigger>
168
- <SelectContent>
169
- {/* Built-in collectors first */}
170
- {builtInCollectors.length > 0 && (
171
- <>
172
- <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
173
- Built-in
174
- </div>
175
- {builtInCollectors
176
- .filter((c) => addableCollectors.some((a) => a.id === c.id))
177
- .map((collector) => (
178
- <SelectItem key={collector.id} value={collector.id}>
179
- <div className="flex items-center gap-2">
180
- <span>{collector.displayName}</span>
181
- {collector.allowMultiple && (
182
- <Badge variant="outline" className="text-xs">
183
- Multiple
184
- </Badge>
185
- )}
186
- </div>
187
- </SelectItem>
188
- ))}
189
- </>
190
- )}
191
- {/* External collectors */}
192
- {externalCollectors.length > 0 && (
193
- <>
194
- <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
195
- External
196
- </div>
197
- {externalCollectors
198
- .filter((c) => addableCollectors.some((a) => a.id === c.id))
199
- .map((collector) => (
200
- <SelectItem key={collector.id} value={collector.id}>
201
- <div className="flex items-center gap-2">
202
- <span>{collector.displayName}</span>
203
- {collector.allowMultiple && (
204
- <Badge variant="outline" className="text-xs">
205
- Multiple
206
- </Badge>
207
- )}
208
- </div>
209
- </SelectItem>
210
- ))}
211
- </>
212
- )}
213
- </SelectContent>
214
- </Select>
215
- )}
161
+ <CardHeader>
162
+ <CardHeaderRow>
163
+ <CardTitle className="text-base">Check Items</CardTitle>
164
+ {addableCollectors.length > 0 && (
165
+ <Select value="" onValueChange={handleAdd}>
166
+ <SelectTrigger className="w-[200px]">
167
+ <Plus className="h-4 w-4 mr-2" />
168
+ <SelectValue placeholder="Add collector..." />
169
+ </SelectTrigger>
170
+ <SelectContent>
171
+ {/* Built-in collectors first */}
172
+ {builtInCollectors.length > 0 && (
173
+ <>
174
+ <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
175
+ Built-in
176
+ </div>
177
+ {builtInCollectors
178
+ .filter((c) =>
179
+ addableCollectors.some((a) => a.id === c.id),
180
+ )
181
+ .map((collector) => (
182
+ <SelectItem key={collector.id} value={collector.id}>
183
+ <div className="flex items-center gap-2">
184
+ <span>{collector.displayName}</span>
185
+ {collector.allowMultiple && (
186
+ <Badge variant="outline" className="text-xs">
187
+ Multiple
188
+ </Badge>
189
+ )}
190
+ </div>
191
+ </SelectItem>
192
+ ))}
193
+ </>
194
+ )}
195
+ {/* External collectors */}
196
+ {externalCollectors.length > 0 && (
197
+ <>
198
+ <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
199
+ External
200
+ </div>
201
+ {externalCollectors
202
+ .filter((c) =>
203
+ addableCollectors.some((a) => a.id === c.id),
204
+ )
205
+ .map((collector) => (
206
+ <SelectItem key={collector.id} value={collector.id}>
207
+ <div className="flex items-center gap-2">
208
+ <span>{collector.displayName}</span>
209
+ {collector.allowMultiple && (
210
+ <Badge variant="outline" className="text-xs">
211
+ Multiple
212
+ </Badge>
213
+ )}
214
+ </div>
215
+ </SelectItem>
216
+ ))}
217
+ </>
218
+ )}
219
+ </SelectContent>
220
+ </Select>
221
+ )}
222
+ </CardHeaderRow>
216
223
  </CardHeader>
217
224
  <CardContent>
218
225
  {configuredCollectors.length === 0 ? (
@@ -0,0 +1,140 @@
1
+ /**
2
+ * ExpandedResultView - Displays health check result data in a structured format.
3
+ */
4
+
5
+ interface ExpandedResultViewProps {
6
+ result: Record<string, unknown>;
7
+ }
8
+
9
+ /**
10
+ * Displays the result data in a structured format.
11
+ * Shows collector results as cards with key-value pairs.
12
+ */
13
+ export function ExpandedResultView({ result }: ExpandedResultViewProps) {
14
+ const metadata = result.metadata as Record<string, unknown> | undefined;
15
+ const rawCollectors = metadata?.collectors;
16
+
17
+ // Type guard for collectors object
18
+ const collectors: Record<string, Record<string, unknown>> | undefined =
19
+ rawCollectors &&
20
+ typeof rawCollectors === "object" &&
21
+ !Array.isArray(rawCollectors)
22
+ ? (rawCollectors as Record<string, Record<string, unknown>>)
23
+ : undefined;
24
+
25
+ // Check if we have collectors to display
26
+ const collectorEntries = collectors ? Object.entries(collectors) : [];
27
+
28
+ // Extract connection time as typed value
29
+ const connectionTimeMs = metadata?.connectionTimeMs as number | undefined;
30
+
31
+ return (
32
+ <div className="space-y-4">
33
+ <div className="flex gap-4 text-sm">
34
+ <div>
35
+ <span className="text-muted-foreground">Status: </span>
36
+ <span className="font-medium">{String(result.status)}</span>
37
+ </div>
38
+ <div>
39
+ <span className="text-muted-foreground">Latency: </span>
40
+ <span className="font-medium">{String(result.latencyMs)}ms</span>
41
+ </div>
42
+ {connectionTimeMs !== undefined && (
43
+ <div>
44
+ <span className="text-muted-foreground">Connection: </span>
45
+ <span className="font-medium">{connectionTimeMs}ms</span>
46
+ </div>
47
+ )}
48
+ </div>
49
+
50
+ {collectorEntries.length > 0 && (
51
+ <div className="space-y-3">
52
+ <h4 className="text-sm font-medium">Collector Results</h4>
53
+ <div className="grid gap-3 md:grid-cols-2">
54
+ {collectorEntries.map(([collectorId, collectorResult]) => (
55
+ <CollectorResultCard
56
+ key={collectorId}
57
+ collectorId={collectorId}
58
+ result={collectorResult}
59
+ />
60
+ ))}
61
+ </div>
62
+ </div>
63
+ )}
64
+
65
+ {result.message ? (
66
+ <div className="text-sm text-muted-foreground">
67
+ {String(result.message)}
68
+ </div>
69
+ ) : undefined}
70
+ </div>
71
+ );
72
+ }
73
+
74
+ interface CollectorResultCardProps {
75
+ collectorId: string;
76
+ result: Record<string, unknown>;
77
+ }
78
+
79
+ /**
80
+ * Card displaying a single collector's result values.
81
+ */
82
+ function CollectorResultCard({
83
+ collectorId,
84
+ result,
85
+ }: CollectorResultCardProps) {
86
+ if (!result || typeof result !== "object") {
87
+ return;
88
+ }
89
+
90
+ // Filter out null/undefined values
91
+ const entries = Object.entries(result).filter(
92
+ ([, value]) => value !== null && value !== undefined,
93
+ );
94
+
95
+ return (
96
+ <div className="rounded-md border bg-card p-3 space-y-2">
97
+ <h5 className="text-sm font-medium text-primary">{collectorId}</h5>
98
+ <div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
99
+ {entries.map(([key, value]) => (
100
+ <div key={key} className="contents">
101
+ <span className="text-muted-foreground truncate">
102
+ {formatKey(key)}
103
+ </span>
104
+ <span className="font-mono text-xs truncate" title={String(value)}>
105
+ {formatValue(value)}
106
+ </span>
107
+ </div>
108
+ ))}
109
+ </div>
110
+ </div>
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Format a camelCase key to a readable label.
116
+ */
117
+ function formatKey(key: string): string {
118
+ return key
119
+ .replaceAll(/([a-z])([A-Z])/g, "$1 $2")
120
+ .replace(/^./, (c) => c.toUpperCase());
121
+ }
122
+
123
+ /**
124
+ * Format a value for display.
125
+ */
126
+ function formatValue(value: unknown): string {
127
+ if (value === null || value === undefined) return "—";
128
+ if (typeof value === "boolean") return value ? "Yes" : "No";
129
+ if (typeof value === "number") {
130
+ return Number.isInteger(value) ? String(value) : value.toFixed(2);
131
+ }
132
+ if (Array.isArray(value)) {
133
+ return value.length > 3
134
+ ? `[${value.slice(0, 3).join(", ")}…]`
135
+ : `[${value.join(", ")}]`;
136
+ }
137
+ if (typeof value === "object") return JSON.stringify(value);
138
+ const str = String(value);
139
+ return str.length > 50 ? `${str.slice(0, 47)}…` : str;
140
+ }
@@ -1,44 +1,4 @@
1
- import { ExtensionSlot } from "@checkstack/frontend-api";
2
1
  import { InfoBanner } from "@checkstack/ui";
3
- import {
4
- HealthCheckDiagramSlot,
5
- type HealthCheckDiagramSlotContext,
6
- } from "../slots";
7
- import { AggregatedDataBanner } from "./AggregatedDataBanner";
8
-
9
- interface HealthCheckDiagramProps {
10
- /** The context from useHealthCheckData - handles both raw and aggregated modes */
11
- context: HealthCheckDiagramSlotContext;
12
- /** Whether the data is aggregated (for showing the info banner) */
13
- isAggregated?: boolean;
14
- /** The bucket interval in seconds (from aggregated response) */
15
- bucketIntervalSeconds?: number;
16
- /** The check interval in seconds (for comparison in banner) */
17
- checkIntervalSeconds?: number;
18
- }
19
-
20
- /**
21
- * Component that renders the diagram extension slot with the provided context.
22
- * Expects parent component to fetch data via useHealthCheckData and pass context.
23
- */
24
- export function HealthCheckDiagram({
25
- context,
26
- isAggregated = false,
27
- bucketIntervalSeconds,
28
- checkIntervalSeconds,
29
- }: HealthCheckDiagramProps) {
30
- return (
31
- <>
32
- {isAggregated && bucketIntervalSeconds && (
33
- <AggregatedDataBanner
34
- bucketIntervalSeconds={bucketIntervalSeconds}
35
- checkIntervalSeconds={checkIntervalSeconds}
36
- />
37
- )}
38
- <ExtensionSlot slot={HealthCheckDiagramSlot} context={context} />
39
- </>
40
- );
41
- }
42
2
 
43
3
  /**
44
4
  * Wrapper that shows access message when user lacks access.
@@ -26,10 +26,10 @@ export const HealthCheckHistory: React.FC<SlotProps> = (props) => {
26
26
 
27
27
  const healthCheckClient = usePluginClient(HealthCheckApi);
28
28
 
29
- // Fetch history with useQuery
29
+ // Fetch history with useQuery - newest first for table view
30
30
  const { data, isLoading: loading } = healthCheckClient.getHistory.useQuery(
31
- { systemId, configurationId, limit },
32
- { enabled: true }
31
+ { systemId, configurationId, limit, sortOrder: "desc" },
32
+ { enabled: true },
33
33
  );
34
34
 
35
35
  const history = data?.runs ?? [];
@@ -40,6 +40,7 @@ export const HealthCheckLatencyChart: React.FC<
40
40
 
41
41
  const chartData = buckets.map((d) => ({
42
42
  timestamp: new Date(d.bucketStart).getTime(),
43
+ bucketEndTimestamp: new Date(d.bucketEnd).getTime(),
43
44
  latencyMs: d.avgLatencyMs!,
44
45
  minLatencyMs: d.minLatencyMs,
45
46
  maxLatencyMs: d.maxLatencyMs,
@@ -54,7 +55,7 @@ export const HealthCheckLatencyChart: React.FC<
54
55
  const timeFormat =
55
56
  (buckets[0]?.bucketIntervalSeconds ?? 3600) >= 21_600
56
57
  ? "MMM d"
57
- : "MMM d HH:mm";
58
+ : "MMM d, HH:mm";
58
59
 
59
60
  return (
60
61
  <ResponsiveContainer width="100%" height={height}>
@@ -90,6 +91,14 @@ export const HealthCheckLatencyChart: React.FC<
90
91
  content={({ active, payload }) => {
91
92
  if (!active || !payload?.length) return;
92
93
  const data = payload[0].payload as (typeof chartData)[number];
94
+ const startTime = format(
95
+ new Date(data.timestamp),
96
+ "MMM d, HH:mm",
97
+ );
98
+ const endTime = format(
99
+ new Date(data.bucketEndTimestamp),
100
+ "HH:mm",
101
+ );
93
102
  return (
94
103
  <div
95
104
  className="rounded-md border bg-popover p-2 text-sm shadow-md"
@@ -99,9 +108,9 @@ export const HealthCheckLatencyChart: React.FC<
99
108
  }}
100
109
  >
101
110
  <p className="text-muted-foreground">
102
- {format(new Date(data.timestamp), "MMM d, HH:mm:ss")}
111
+ {startTime} - {endTime}
103
112
  </p>
104
- <p className="font-medium">{data.latencyMs}ms</p>
113
+ <p className="font-medium">{data.latencyMs}ms (avg)</p>
105
114
  </div>
106
115
  );
107
116
  }}
@@ -145,7 +154,8 @@ export const HealthCheckLatencyChart: React.FC<
145
154
  );
146
155
  }
147
156
 
148
- const chartData = runs.toReversed().map((d) => ({
157
+ // Runs come in chronological order from API (oldest first, newest last)
158
+ const chartData = runs.map((d) => ({
149
159
  timestamp: new Date(d.timestamp).getTime(),
150
160
  latencyMs: d.latencyMs!,
151
161
  }));
@@ -11,14 +11,18 @@ import {
11
11
  TableHeader,
12
12
  TableRow,
13
13
  Button,
14
+ Badge,
14
15
  } from "@checkstack/ui";
15
- import { Trash2, Edit } from "lucide-react";
16
+ import { Trash2, Edit, Pause, Play } from "lucide-react";
16
17
 
17
18
  interface HealthCheckListProps {
18
19
  configurations: HealthCheckConfiguration[];
19
20
  strategies: HealthCheckStrategyDto[];
20
21
  onEdit: (config: HealthCheckConfiguration) => void;
21
22
  onDelete: (id: string) => void;
23
+ onPause?: (id: string) => void;
24
+ onResume?: (id: string) => void;
25
+ canManage?: boolean;
22
26
  }
23
27
 
24
28
  export const HealthCheckList: React.FC<HealthCheckListProps> = ({
@@ -26,6 +30,9 @@ export const HealthCheckList: React.FC<HealthCheckListProps> = ({
26
30
  strategies,
27
31
  onEdit,
28
32
  onDelete,
33
+ onPause,
34
+ onResume,
35
+ canManage = true,
29
36
  }) => {
30
37
  const getStrategyName = (id: string) => {
31
38
  return strategies.find((s) => s.id === id)?.displayName || id;
@@ -39,24 +46,57 @@ export const HealthCheckList: React.FC<HealthCheckListProps> = ({
39
46
  <TableHead>Name</TableHead>
40
47
  <TableHead>Strategy</TableHead>
41
48
  <TableHead>Interval (s)</TableHead>
49
+ <TableHead>Status</TableHead>
42
50
  <TableHead className="text-right">Actions</TableHead>
43
51
  </TableRow>
44
52
  </TableHeader>
45
53
  <TableBody>
46
54
  {configurations.length === 0 ? (
47
55
  <TableRow>
48
- <TableCell colSpan={4} className="h-24 text-center">
56
+ <TableCell colSpan={5} className="h-24 text-center">
49
57
  No health checks configured.
50
58
  </TableCell>
51
59
  </TableRow>
52
60
  ) : (
53
61
  configurations.map((config) => (
54
- <TableRow key={config.id}>
62
+ <TableRow
63
+ key={config.id}
64
+ className={config.paused ? "opacity-60" : ""}
65
+ >
55
66
  <TableCell className="font-medium">{config.name}</TableCell>
56
67
  <TableCell>{getStrategyName(config.strategyId)}</TableCell>
57
68
  <TableCell>{config.intervalSeconds}</TableCell>
69
+ <TableCell>
70
+ {config.paused ? (
71
+ <Badge variant="secondary">Paused</Badge>
72
+ ) : (
73
+ <Badge variant="default">Active</Badge>
74
+ )}
75
+ </TableCell>
58
76
  <TableCell className="text-right">
59
77
  <div className="flex justify-end gap-2">
78
+ {canManage &&
79
+ onPause &&
80
+ onResume &&
81
+ (config.paused ? (
82
+ <Button
83
+ variant="ghost"
84
+ size="icon"
85
+ onClick={() => onResume(config.id)}
86
+ title="Resume health check"
87
+ >
88
+ <Play className="h-4 w-4" />
89
+ </Button>
90
+ ) : (
91
+ <Button
92
+ variant="ghost"
93
+ size="icon"
94
+ onClick={() => onPause(config.id)}
95
+ title="Pause health check"
96
+ >
97
+ <Pause className="h-4 w-4" />
98
+ </Button>
99
+ ))}
60
100
  <Button
61
101
  variant="ghost"
62
102
  size="icon"
@@ -64,14 +104,16 @@ export const HealthCheckList: React.FC<HealthCheckListProps> = ({
64
104
  >
65
105
  <Edit className="h-4 w-4" />
66
106
  </Button>
67
- <Button
68
- variant="ghost"
69
- size="icon"
70
- className="text-destructive hover:text-destructive"
71
- onClick={() => onDelete(config.id)}
72
- >
73
- <Trash2 className="h-4 w-4" />
74
- </Button>
107
+ {canManage && (
108
+ <Button
109
+ variant="ghost"
110
+ size="icon"
111
+ className="text-destructive hover:text-destructive"
112
+ onClick={() => onDelete(config.id)}
113
+ >
114
+ <Trash2 className="h-4 w-4" />
115
+ </Button>
116
+ )}
75
117
  </div>
76
118
  </TableCell>
77
119
  </TableRow>