@checkstack/healthcheck-frontend 0.4.10 → 0.6.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,128 @@
1
1
  # @checkstack/healthcheck-frontend
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 11d2679: Add ability to pause health check configurations globally. When paused, health checks continue to be scheduled but execution is skipped for all systems using that configuration. Users with manage access can pause/resume from the Health Checks config page.
8
+
9
+ ### Patch Changes
10
+
11
+ - 223081d: Add icon support to PageLayout and improve mobile responsiveness
12
+
13
+ **PageLayout Icons:**
14
+
15
+ - Added required `icon` prop to `PageLayout` and `PageHeader` components that accepts a Lucide icon component reference
16
+ - Icons are rendered with consistent `h-6 w-6 text-primary` styling
17
+ - Updated all page components to include appropriate icons in their headers
18
+
19
+ **Mobile Layout Improvements:**
20
+
21
+ - Standardized responsive padding in main app shell (`p-3` on mobile, `p-6` on desktop)
22
+ - Added `CardHeaderRow` component for mobile-safe card headers with proper wrapping
23
+ - Improved `DateRangeFilter` responsive behavior with vertical stacking on mobile
24
+ - Migrated pages to use `PageLayout` for consistent responsive behavior
25
+
26
+ - Updated dependencies [11d2679]
27
+ - Updated dependencies [223081d]
28
+ - @checkstack/healthcheck-common@0.6.0
29
+ - @checkstack/ui@0.5.0
30
+ - @checkstack/auth-frontend@0.5.5
31
+ - @checkstack/dashboard-frontend@0.3.10
32
+
33
+ ## 0.5.0
34
+
35
+ ### Minor Changes
36
+
37
+ - ac3a4cf: ### Dynamic Bucket Sizing for Health Check Visualization
38
+
39
+ Implements industry-standard dynamic bucket sizing for health check data aggregation, following patterns from Grafana/VictoriaMetrics.
40
+
41
+ **What changed:**
42
+
43
+ - Replaced fixed `bucketSize: "hourly" | "daily" | "auto"` with dynamic `targetPoints` parameter (default: 500)
44
+ - Bucket interval is now calculated as `(endDate - startDate) / targetPoints` with a minimum of 1 second
45
+ - Added `bucketIntervalSeconds` to aggregated response and individual buckets
46
+ - Updated chart components to use dynamic time formatting based on bucket interval
47
+
48
+ **Why:**
49
+
50
+ - A 24-hour view with 1-second health checks previously returned 86,400+ data points, causing lag
51
+ - Now returns ~500 data points regardless of timeframe, ensuring consistent chart performance
52
+ - Charts still preserve visual fidelity through proper aggregation
53
+
54
+ **Breaking Change:**
55
+
56
+ - `bucketSize` parameter removed from `getAggregatedHistory` and `getDetailedAggregatedHistory` endpoints
57
+ - Use `targetPoints` instead (defaults to 500 if not specified)
58
+
59
+ ***
60
+
61
+ ### Collector Aggregated Charts Fix
62
+
63
+ Fixed issue where collector auto-charts (like HTTP request response time charts) were not showing in aggregated data mode.
64
+
65
+ **What changed:**
66
+
67
+ - Added `aggregatedResultSchema` to `CollectorDtoSchema`
68
+ - Backend now returns collector aggregated schemas via `getCollectors` endpoint
69
+ - Frontend `useStrategySchemas` hook now merges collector aggregated schemas
70
+ - Service now calls each collector's `aggregateResult()` when building buckets
71
+ - Aggregated collector data stored in `aggregatedResult.collectors[uuid]`
72
+
73
+ **Why:**
74
+
75
+ - Previously only strategy-level aggregated results were computed
76
+ - Collectors like HTTP Request Collector have their own `aggregateResult` method
77
+ - Without calling these, fields like `avgResponseTimeMs` and `successRate` were missing from aggregated buckets
78
+
79
+ ### Patch Changes
80
+
81
+ - 095cf4e: ### Cross-Tier Data Aggregation
82
+
83
+ Implements intelligent cross-tier querying for health check history, enabling seamless data retrieval across raw, hourly, and daily storage tiers.
84
+
85
+ **What changed:**
86
+
87
+ - `getAggregatedHistory` now queries all three tiers (raw, hourly, daily) in parallel
88
+ - Added `NormalizedBucket` type for unified bucket format across tiers
89
+ - Added `mergeTieredBuckets()` to merge data with priority (raw > hourly > daily)
90
+ - Added `combineBuckets()` and `reaggregateBuckets()` for re-aggregation to target bucket size
91
+ - Raw data preserves full granularity when available (uses target bucket interval)
92
+
93
+ **Why:**
94
+
95
+ - Previously, the API only queried raw runs, which are retained for a limited period (default 7 days)
96
+ - For longer time ranges, data was missing because hourly/daily aggregates weren't queried
97
+ - The retention job only runs periodically, so we can't assume tier boundaries based on config
98
+ - Querying all tiers ensures no gaps in data coverage
99
+
100
+ **Technical details:**
101
+
102
+ - Additive metrics (counts, latencySum) are summed correctly for accurate averages
103
+ - p95 latency uses max of source p95s as conservative upper-bound approximation
104
+ - `aggregatedResult` (strategy-specific) is preserved for raw-only buckets
105
+
106
+ - 538e45d: Fixed 24-hour date range not returning correct data and improved chart display
107
+
108
+ - Fixed missing `endDate` parameter in raw data queries causing data to extend beyond selected time range
109
+ - Fixed incorrect 24-hour date calculation using `setHours()` - now uses `date-fns` `subHours()` for correct date math
110
+ - Refactored `DateRangePreset` from string union to enum for improved type safety and IDE support
111
+ - Exported `getPresetRange` function for reuse across components
112
+ - Changed chart x-axis domain from `["auto", "auto"]` to `["dataMin", "dataMax"]` to remove padding gaps
113
+
114
+ - Updated dependencies [ac3a4cf]
115
+ - Updated dependencies [db1f56f]
116
+ - Updated dependencies [538e45d]
117
+ - @checkstack/healthcheck-common@0.5.0
118
+ - @checkstack/common@0.6.0
119
+ - @checkstack/ui@0.4.1
120
+ - @checkstack/dashboard-frontend@0.3.9
121
+ - @checkstack/auth-frontend@0.5.4
122
+ - @checkstack/catalog-common@1.2.4
123
+ - @checkstack/frontend-api@0.3.3
124
+ - @checkstack/signal-frontend@0.0.10
125
+
3
126
  ## 0.4.10
4
127
 
5
128
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-frontend",
3
- "version": "0.4.10",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "main": "src/index.tsx",
6
6
  "scripts": {
@@ -38,7 +38,7 @@ export function useStrategySchemas(strategyId: string): {
38
38
  // Fetch collectors with useQuery
39
39
  const { data: collectors } = healthCheckClient.getCollectors.useQuery(
40
40
  { strategyId },
41
- { enabled: !!strategyId }
41
+ { enabled: !!strategyId },
42
42
  );
43
43
 
44
44
  useEffect(() => {
@@ -51,22 +51,34 @@ export function useStrategySchemas(strategyId: string): {
51
51
  if (strategy) {
52
52
  // Build collector schemas object for nesting under resultSchema.properties.collectors
53
53
  const collectorProperties: Record<string, unknown> = {};
54
+ const collectorAggregatedProperties: Record<string, unknown> = {};
55
+
54
56
  for (const collector of collectors) {
55
57
  // Use full ID so it matches stored data keys like "healthcheck-http.request"
56
58
  collectorProperties[collector.id] = collector.resultSchema;
59
+
60
+ // Also collect aggregated schemas if available
61
+ if (collector.aggregatedResultSchema) {
62
+ collectorAggregatedProperties[collector.id] =
63
+ collector.aggregatedResultSchema;
64
+ }
57
65
  }
58
66
 
59
67
  // Merge collector schemas into strategy result schema
60
68
  const mergedResultSchema = mergeCollectorSchemas(
61
69
  strategy.resultSchema as Record<string, unknown> | undefined,
62
- collectorProperties
70
+ collectorProperties,
71
+ );
72
+
73
+ // Merge collector aggregated schemas into strategy aggregated schema
74
+ const mergedAggregatedSchema = mergeCollectorSchemas(
75
+ strategy.aggregatedResultSchema as Record<string, unknown> | undefined,
76
+ collectorAggregatedProperties,
63
77
  );
64
78
 
65
79
  setSchemas({
66
80
  resultSchema: mergedResultSchema,
67
- aggregatedResultSchema:
68
- (strategy.aggregatedResultSchema as Record<string, unknown>) ??
69
- undefined,
81
+ aggregatedResultSchema: mergedAggregatedSchema,
70
82
  });
71
83
  }
72
84
 
@@ -85,7 +97,7 @@ export function useStrategySchemas(strategyId: string): {
85
97
  */
86
98
  function mergeCollectorSchemas(
87
99
  strategySchema: Record<string, unknown> | undefined,
88
- collectorProperties: Record<string, unknown>
100
+ collectorProperties: Record<string, unknown>,
89
101
  ): Record<string, unknown> | undefined {
90
102
  // If no collectors, return original schema
91
103
  if (Object.keys(collectorProperties).length === 0) {
@@ -1,24 +1,67 @@
1
1
  import { InfoBanner } from "@checkstack/ui";
2
2
 
3
3
  interface AggregatedDataBannerProps {
4
- bucketSize: "hourly" | "daily";
5
- rawRetentionDays: number;
4
+ /** Bucket interval in seconds */
5
+ bucketIntervalSeconds: number;
6
+ /** The configured check interval in seconds (optional, for comparison) */
7
+ checkIntervalSeconds?: number;
6
8
  }
7
9
 
10
+ /**
11
+ * Format seconds into a human-readable duration string.
12
+ */
13
+ function formatDuration(seconds: number): string {
14
+ if (seconds < 60) {
15
+ return `${seconds}s`;
16
+ }
17
+ if (seconds < 3600) {
18
+ const mins = Math.round(seconds / 60);
19
+ return `${mins}min`;
20
+ }
21
+ const hours = Math.round(seconds / 3600);
22
+ return `${hours}h`;
23
+ }
24
+
25
+ /**
26
+ * Get resolution tier label based on bucket interval.
27
+ */
28
+ function getResolutionTier(
29
+ bucketIntervalSeconds: number,
30
+ ): "high" | "medium" | "low" {
31
+ if (bucketIntervalSeconds >= 86_400) {
32
+ return "low"; // Daily aggregates
33
+ }
34
+ if (bucketIntervalSeconds >= 3600) {
35
+ return "medium"; // Hourly aggregates
36
+ }
37
+ return "high"; // Raw data
38
+ }
39
+
40
+ const TIER_LABELS: Record<"high" | "medium" | "low", string> = {
41
+ high: "High resolution",
42
+ medium: "Medium resolution",
43
+ low: "Low resolution",
44
+ };
45
+
8
46
  /**
9
47
  * Banner shown when viewing aggregated health check data.
10
- * Informs users about the aggregation level and how to see detailed data.
48
+ * Informs users about the aggregation level due to high data volume.
11
49
  */
12
50
  export function AggregatedDataBanner({
13
- bucketSize,
14
- rawRetentionDays,
51
+ bucketIntervalSeconds,
52
+ checkIntervalSeconds,
15
53
  }: AggregatedDataBannerProps) {
16
- const bucketLabel = bucketSize === "hourly" ? "hourly" : "daily";
54
+ // Only show if bucket interval is larger than check interval (data is being aggregated)
55
+ if (checkIntervalSeconds && bucketIntervalSeconds <= checkIntervalSeconds) {
56
+ return;
57
+ }
58
+
59
+ const tier = getResolutionTier(bucketIntervalSeconds);
17
60
 
18
61
  return (
19
62
  <InfoBanner variant="info">
20
- Showing {bucketLabel} aggregates. For per-run data, select a range ≤{" "}
21
- {rawRetentionDays} days.
63
+ {TIER_LABELS[tier]} Data aggregated into{" "}
64
+ {formatDuration(bucketIntervalSeconds)} intervals
22
65
  </InfoBanner>
23
66
  );
24
67
  }
@@ -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 ? (
@@ -11,8 +11,10 @@ interface HealthCheckDiagramProps {
11
11
  context: HealthCheckDiagramSlotContext;
12
12
  /** Whether the data is aggregated (for showing the info banner) */
13
13
  isAggregated?: boolean;
14
- /** Raw retention days (for the info banner) */
15
- rawRetentionDays?: number;
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;
16
18
  }
17
19
 
18
20
  /**
@@ -22,20 +24,15 @@ interface HealthCheckDiagramProps {
22
24
  export function HealthCheckDiagram({
23
25
  context,
24
26
  isAggregated = false,
25
- rawRetentionDays = 7,
27
+ bucketIntervalSeconds,
28
+ checkIntervalSeconds,
26
29
  }: HealthCheckDiagramProps) {
27
- // Determine bucket size from context for aggregated data info banner
28
- const bucketSize =
29
- context.type === "aggregated" && context.buckets.length > 0
30
- ? context.buckets[0].bucketSize
31
- : "hourly";
32
-
33
30
  return (
34
31
  <>
35
- {isAggregated && (
32
+ {isAggregated && bucketIntervalSeconds && (
36
33
  <AggregatedDataBanner
37
- bucketSize={bucketSize}
38
- rawRetentionDays={rawRetentionDays}
34
+ bucketIntervalSeconds={bucketIntervalSeconds}
35
+ checkIntervalSeconds={checkIntervalSeconds}
39
36
  />
40
37
  )}
41
38
  <ExtensionSlot slot={HealthCheckDiagramSlot} context={context} />
@@ -50,8 +50,11 @@ export const HealthCheckLatencyChart: React.FC<
50
50
  ? chartData.reduce((sum, d) => sum + d.latencyMs, 0) / chartData.length
51
51
  : 0;
52
52
 
53
+ // Use daily format for intervals >= 6 hours, otherwise include time
53
54
  const timeFormat =
54
- buckets[0]?.bucketSize === "daily" ? "MMM d" : "MMM d HH:mm";
55
+ (buckets[0]?.bucketIntervalSeconds ?? 3600) >= 21_600
56
+ ? "MMM d"
57
+ : "MMM d HH:mm";
55
58
 
56
59
  return (
57
60
  <ResponsiveContainer width="100%" height={height}>
@@ -73,7 +76,7 @@ export const HealthCheckLatencyChart: React.FC<
73
76
  <XAxis
74
77
  dataKey="timestamp"
75
78
  type="number"
76
- domain={["auto", "auto"]}
79
+ domain={["dataMin", "dataMax"]}
77
80
  tickFormatter={(ts: number) => format(new Date(ts), timeFormat)}
78
81
  stroke="hsl(var(--muted-foreground))"
79
82
  fontSize={12}
@@ -172,7 +175,7 @@ export const HealthCheckLatencyChart: React.FC<
172
175
  <XAxis
173
176
  dataKey="timestamp"
174
177
  type="number"
175
- domain={["auto", "auto"]}
178
+ domain={["dataMin", "dataMax"]}
176
179
  tickFormatter={(ts: number) => format(new Date(ts), "HH:mm")}
177
180
  stroke="hsl(var(--muted-foreground))"
178
181
  fontSize={12}
@@ -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>
@@ -50,8 +50,11 @@ export const HealthCheckStatusTimeline: React.FC<
50
50
  total: d.runCount,
51
51
  }));
52
52
 
53
+ // Use daily format for intervals >= 6 hours, otherwise include time
53
54
  const timeFormat =
54
- buckets[0]?.bucketSize === "daily" ? "MMM d" : "MMM d HH:mm";
55
+ (buckets[0]?.bucketIntervalSeconds ?? 3600) >= 21_600
56
+ ? "MMM d"
57
+ : "MMM d HH:mm";
55
58
 
56
59
  return (
57
60
  <ResponsiveContainer width="100%" height={height}>
@@ -59,7 +62,7 @@ export const HealthCheckStatusTimeline: React.FC<
59
62
  <XAxis
60
63
  dataKey="timestamp"
61
64
  type="number"
62
- domain={["auto", "auto"]}
65
+ domain={["dataMin", "dataMax"]}
63
66
  tickFormatter={(ts: number) => format(new Date(ts), timeFormat)}
64
67
  stroke="hsl(var(--muted-foreground))"
65
68
  fontSize={10}
@@ -133,7 +136,7 @@ export const HealthCheckStatusTimeline: React.FC<
133
136
  <XAxis
134
137
  dataKey="timestamp"
135
138
  type="number"
136
- domain={["auto", "auto"]}
139
+ domain={["dataMin", "dataMax"]}
137
140
  tickFormatter={(ts: number) => format(new Date(ts), "HH:mm")}
138
141
  stroke="hsl(var(--muted-foreground))"
139
142
  fontSize={10}
@@ -20,6 +20,8 @@ import {
20
20
  usePagination,
21
21
  usePaginationSync,
22
22
  DateRangeFilter,
23
+ getPresetRange,
24
+ DateRangePreset,
23
25
  } from "@checkstack/ui";
24
26
  import { formatDistanceToNow } from "date-fns";
25
27
  import { ChevronDown, ChevronRight } from "lucide-react";
@@ -55,17 +57,10 @@ interface ExpandedRowProps {
55
57
  const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
56
58
  const healthCheckClient = usePluginClient(HealthCheckApi);
57
59
 
58
- // Date range state for filtering
59
- const [dateRange, setDateRange] = useState<{
60
- startDate: Date;
61
- endDate: Date;
62
- }>(() => {
63
- // Default to last 24 hours
64
- const end = new Date();
65
- const start = new Date();
66
- start.setHours(start.getHours() - 24);
67
- return { startDate: start, endDate: end };
68
- });
60
+ // Date range state for filtering - default to last 24 hours
61
+ const [dateRange, setDateRange] = useState(() =>
62
+ getPresetRange(DateRangePreset.Last24Hours),
63
+ );
69
64
 
70
65
  // Use shared hook for chart data - handles both raw and aggregated modes
71
66
  // and includes signal handling for automatic refresh
@@ -73,7 +68,7 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
73
68
  context: chartContext,
74
69
  loading: chartLoading,
75
70
  isAggregated,
76
- retentionConfig,
71
+ bucketIntervalSeconds,
77
72
  } = useHealthCheckData({
78
73
  systemId,
79
74
  configurationId: item.configurationId,
@@ -160,7 +155,8 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
160
155
  <HealthCheckDiagram
161
156
  context={chartContext}
162
157
  isAggregated={isAggregated}
163
- rawRetentionDays={retentionConfig.rawRetentionDays}
158
+ bucketIntervalSeconds={bucketIntervalSeconds}
159
+ checkIntervalSeconds={item.intervalSeconds}
164
160
  />
165
161
  </div>
166
162
  );
@@ -184,10 +180,7 @@ const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
184
180
  </div>
185
181
 
186
182
  {/* Date Range Filter */}
187
- <div className="flex items-center gap-2">
188
- <span className="text-sm text-muted-foreground">Time Range:</span>
189
- <DateRangeFilter value={dateRange} onChange={setDateRange} />
190
- </div>
183
+ <DateRangeFilter value={dateRange} onChange={setDateRange} />
191
184
 
192
185
  {/* Charts Section */}
193
186
  {renderCharts()}
@@ -281,8 +274,8 @@ export function HealthCheckSystemOverview(props: SlotProps) {
281
274
  void refetch();
282
275
  }
283
276
  },
284
- [systemId, refetch]
285
- )
277
+ [systemId, refetch],
278
+ ),
286
279
  );
287
280
 
288
281
  if (initialLoading) {
@@ -305,28 +298,31 @@ export function HealthCheckSystemOverview(props: SlotProps) {
305
298
  return (
306
299
  <div key={item.configurationId} className="rounded-md border bg-card">
307
300
  <button
308
- className="w-full flex items-center justify-between p-4 text-left hover:bg-muted/50 transition-colors"
301
+ className="w-full p-4 text-left hover:bg-muted/50 transition-colors"
309
302
  onClick={() =>
310
303
  setExpandedRow(isExpanded ? undefined : item.configurationId)
311
304
  }
312
305
  >
306
+ {/* Header row: chevron, name, badge */}
313
307
  <div className="flex items-center gap-3">
314
308
  {isExpanded ? (
315
- <ChevronDown className="h-4 w-4 text-muted-foreground" />
309
+ <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
316
310
  ) : (
317
- <ChevronRight className="h-4 w-4 text-muted-foreground" />
311
+ <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
318
312
  )}
319
- <div>
320
- <div className="font-medium">{item.name}</div>
321
- <div className="text-sm text-muted-foreground">
322
- Last run:{" "}
323
- {item.lastRunAt
324
- ? formatDistanceToNow(item.lastRunAt, { addSuffix: true })
325
- : "never"}
326
- </div>
313
+ <div className="flex-1 min-w-0 flex items-center justify-between gap-2">
314
+ <span className="font-medium truncate">{item.name}</span>
315
+ <HealthBadge status={item.state} />
327
316
  </div>
328
317
  </div>
329
- <div className="flex items-center gap-4">
318
+ {/* Details row: last run + sparkline */}
319
+ <div className="ml-7 mt-1 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
320
+ <span className="text-sm text-muted-foreground">
321
+ Last run:{" "}
322
+ {item.lastRunAt
323
+ ? formatDistanceToNow(item.lastRunAt, { addSuffix: true })
324
+ : "never"}
325
+ </span>
330
326
  {item.recentStatusHistory.length > 0 && (
331
327
  <HealthCheckSparkline
332
328
  runs={item.recentStatusHistory.map((status) => ({
@@ -334,7 +330,6 @@ export function HealthCheckSystemOverview(props: SlotProps) {
334
330
  }))}
335
331
  />
336
332
  )}
337
- <HealthBadge status={item.state} />
338
333
  </div>
339
334
  </button>
340
335
  {isExpanded && <ExpandedDetails item={item} systemId={systemId} />}
@@ -44,6 +44,8 @@ interface UseHealthCheckDataResult {
44
44
  hasAccess: boolean;
45
45
  /** Whether access is still loading */
46
46
  accessLoading: boolean;
47
+ /** Bucket interval in seconds (only for aggregated mode) */
48
+ bucketIntervalSeconds: number | undefined;
47
49
  }
48
50
 
49
51
  /**
@@ -67,14 +69,14 @@ export function useHealthCheckData({
67
69
 
68
70
  // Access state
69
71
  const { allowed: hasAccess, loading: accessLoading } = accessApi.useAccess(
70
- healthCheckAccess.details
72
+ healthCheckAccess.details,
71
73
  );
72
74
 
73
75
  // Calculate date range in days
74
76
  const dateRangeDays = useMemo(() => {
75
77
  return Math.ceil(
76
78
  (dateRange.endDate.getTime() - dateRange.startDate.getTime()) /
77
- (1000 * 60 * 60 * 24)
79
+ (1000 * 60 * 60 * 24),
78
80
  );
79
81
  }, [dateRange.startDate, dateRange.endDate]);
80
82
 
@@ -82,7 +84,7 @@ export function useHealthCheckData({
82
84
  const { data: retentionData, isLoading: retentionLoading } =
83
85
  healthCheckClient.getRetentionConfig.useQuery(
84
86
  { systemId, configurationId },
85
- { enabled: !!systemId && !!configurationId && hasAccess }
87
+ { enabled: !!systemId && !!configurationId && hasAccess },
86
88
  );
87
89
 
88
90
  const retentionConfig =
@@ -101,6 +103,7 @@ export function useHealthCheckData({
101
103
  systemId,
102
104
  configurationId,
103
105
  startDate: dateRange.startDate,
106
+ endDate: dateRange.endDate,
104
107
  limit,
105
108
  offset,
106
109
  },
@@ -112,11 +115,10 @@ export function useHealthCheckData({
112
115
  !accessLoading &&
113
116
  !retentionLoading &&
114
117
  !isAggregated,
115
- }
118
+ },
116
119
  );
117
120
 
118
121
  // Query: Fetch aggregated data (when in aggregated mode)
119
- const bucketSize = dateRangeDays > 30 ? "daily" : "hourly";
120
122
  const { data: aggregatedData, isLoading: aggregatedLoading } =
121
123
  healthCheckClient.getDetailedAggregatedHistory.useQuery(
122
124
  {
@@ -124,7 +126,7 @@ export function useHealthCheckData({
124
126
  configurationId,
125
127
  startDate: dateRange.startDate,
126
128
  endDate: dateRange.endDate,
127
- bucketSize,
129
+ targetPoints: 500,
128
130
  },
129
131
  {
130
132
  enabled:
@@ -134,7 +136,7 @@ export function useHealthCheckData({
134
136
  !accessLoading &&
135
137
  !retentionLoading &&
136
138
  isAggregated,
137
- }
139
+ },
138
140
  );
139
141
 
140
142
  // Listen for realtime health check updates to refresh data silently
@@ -223,5 +225,6 @@ export function useHealthCheckData({
223
225
  retentionConfig,
224
226
  hasAccess,
225
227
  accessLoading,
228
+ bucketIntervalSeconds: aggregatedData?.bucketIntervalSeconds,
226
229
  };
227
230
  }
@@ -21,7 +21,7 @@ import {
21
21
  PageLayout,
22
22
  useToast,
23
23
  } from "@checkstack/ui";
24
- import { Plus, History } from "lucide-react";
24
+ import { Plus, History, Activity } from "lucide-react";
25
25
  import { Link } from "react-router-dom";
26
26
  import { resolveRoute } from "@checkstack/common";
27
27
 
@@ -31,10 +31,10 @@ const HealthCheckConfigPageContent = () => {
31
31
  const toast = useToast();
32
32
  const [searchParams, setSearchParams] = useSearchParams();
33
33
  const { allowed: canRead, loading: accessLoading } = accessApi.useAccess(
34
- healthCheckAccess.configuration.read
34
+ healthCheckAccess.configuration.read,
35
35
  );
36
36
  const { allowed: canManage } = accessApi.useAccess(
37
- healthCheckAccess.configuration.manage
37
+ healthCheckAccess.configuration.manage,
38
38
  );
39
39
 
40
40
  const [isEditorOpen, setIsEditorOpen] = useState(false);
@@ -52,7 +52,7 @@ const HealthCheckConfigPageContent = () => {
52
52
 
53
53
  // Fetch strategies with useQuery
54
54
  const { data: strategies = [] } = healthCheckClient.getStrategies.useQuery(
55
- {}
55
+ {},
56
56
  );
57
57
 
58
58
  const configurations = configurationsData?.configurations ?? [];
@@ -100,6 +100,24 @@ const HealthCheckConfigPageContent = () => {
100
100
  },
101
101
  });
102
102
 
103
+ const pauseMutation = healthCheckClient.pauseConfiguration.useMutation({
104
+ onSuccess: () => {
105
+ void refetchConfigurations();
106
+ },
107
+ onError: (error) => {
108
+ toast.error(error instanceof Error ? error.message : "Failed to pause");
109
+ },
110
+ });
111
+
112
+ const resumeMutation = healthCheckClient.resumeConfiguration.useMutation({
113
+ onSuccess: () => {
114
+ void refetchConfigurations();
115
+ },
116
+ onError: (error) => {
117
+ toast.error(error instanceof Error ? error.message : "Failed to resume");
118
+ },
119
+ });
120
+
103
121
  const handleCreate = () => {
104
122
  setEditingConfig(undefined);
105
123
  setIsEditorOpen(true);
@@ -137,6 +155,7 @@ const HealthCheckConfigPageContent = () => {
137
155
  <PageLayout
138
156
  title="Health Checks"
139
157
  subtitle="Manage health check configurations"
158
+ icon={Activity}
140
159
  loading={accessLoading}
141
160
  allowed={canRead}
142
161
  actions={
@@ -159,6 +178,9 @@ const HealthCheckConfigPageContent = () => {
159
178
  strategies={strategies}
160
179
  onEdit={handleEdit}
161
180
  onDelete={handleDelete}
181
+ onPause={(id) => pauseMutation.mutate(id)}
182
+ onResume={(id) => resumeMutation.mutate(id)}
183
+ canManage={canManage}
162
184
  />
163
185
 
164
186
  <HealthCheckEditor
@@ -184,5 +206,5 @@ const HealthCheckConfigPageContent = () => {
184
206
  };
185
207
 
186
208
  export const HealthCheckConfigPage = wrapInSuspense(
187
- HealthCheckConfigPageContent
209
+ HealthCheckConfigPageContent,
188
210
  );
@@ -25,6 +25,7 @@ import {
25
25
  type DateRange,
26
26
  } from "@checkstack/ui";
27
27
  import { useParams } from "react-router-dom";
28
+ import { History } from "lucide-react";
28
29
  import {
29
30
  HealthCheckRunsTable,
30
31
  type HealthCheckRunDetailed,
@@ -39,7 +40,7 @@ const HealthCheckHistoryDetailPageContent = () => {
39
40
  const healthCheckClient = usePluginClient(HealthCheckApi);
40
41
  const accessApi = useApi(accessApiRef);
41
42
  const { allowed: canManage, loading: accessLoading } = accessApi.useAccess(
42
- healthCheckAccess.configuration.manage
43
+ healthCheckAccess.configuration.manage,
43
44
  );
44
45
 
45
46
  const [dateRange, setDateRange] = useState<DateRange>(getDefaultDateRange);
@@ -67,8 +68,9 @@ const HealthCheckHistoryDetailPageContent = () => {
67
68
  title="Health Check Run History"
68
69
  subtitle={`System: ${systemId} • Configuration: ${configurationId?.slice(
69
70
  0,
70
- 8
71
+ 8,
71
72
  )}...`}
73
+ icon={History}
72
74
  loading={accessLoading}
73
75
  allowed={canManage}
74
76
  actions={
@@ -100,5 +102,5 @@ const HealthCheckHistoryDetailPageContent = () => {
100
102
  };
101
103
 
102
104
  export const HealthCheckHistoryDetailPage = wrapInSuspense(
103
- HealthCheckHistoryDetailPageContent
105
+ HealthCheckHistoryDetailPageContent,
104
106
  );
@@ -21,12 +21,13 @@ import {
21
21
  healthCheckAccess,
22
22
  HealthCheckApi,
23
23
  } from "@checkstack/healthcheck-common";
24
+ import { History } from "lucide-react";
24
25
 
25
26
  const HealthCheckHistoryPageContent = () => {
26
27
  const healthCheckClient = usePluginClient(HealthCheckApi);
27
28
  const accessApi = useApi(accessApiRef);
28
29
  const { allowed: canManage, loading: accessLoading } = accessApi.useAccess(
29
- healthCheckAccess.configuration.manage
30
+ healthCheckAccess.configuration.manage,
30
31
  );
31
32
 
32
33
  // Pagination state
@@ -47,6 +48,7 @@ const HealthCheckHistoryPageContent = () => {
47
48
  <PageLayout
48
49
  title="Health Check History"
49
50
  subtitle="Detailed run history with full result data"
51
+ icon={History}
50
52
  loading={accessLoading}
51
53
  allowed={canManage}
52
54
  >
@@ -68,5 +70,5 @@ const HealthCheckHistoryPageContent = () => {
68
70
  };
69
71
 
70
72
  export const HealthCheckHistoryPage = wrapInSuspense(
71
- HealthCheckHistoryPageContent
73
+ HealthCheckHistoryPageContent,
72
74
  );