@checkstack/healthcheck-frontend 0.14.2 → 0.16.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.
@@ -1,10 +1,6 @@
1
- import React, { useState, useCallback, useRef } from "react";
2
- import { Loader2 } from "lucide-react";
1
+ import React, { useState, useCallback } from "react";
3
2
  import {
4
- ExtensionSlot,
5
3
  usePluginClient,
6
- useApi,
7
- accessApiRef,
8
4
  type SlotContext,
9
5
  } from "@checkstack/frontend-api";
10
6
  import { useSignal } from "@checkstack/signal-frontend";
@@ -12,54 +8,42 @@ import { SystemDetailsSlot } from "@checkstack/catalog-common";
12
8
  import {
13
9
  HEALTH_CHECK_RUN_COMPLETED,
14
10
  HealthCheckApi,
15
- healthCheckAccess,
16
- healthcheckRoutes,
17
11
  } from "@checkstack/healthcheck-common";
18
- import { SatelliteApi, satelliteAccess } from "@checkstack/satellite-common";
19
- import { resolveRoute } from "@checkstack/common";
20
12
  import {
21
13
  HealthBadge,
22
14
  LoadingSpinner,
23
- Table,
24
- TableHeader,
25
- TableRow,
26
- TableHead,
27
- TableBody,
28
- TableCell,
29
- Tooltip,
30
- Pagination,
31
- usePagination,
32
- usePaginationSync,
33
- DateRangeFilter,
34
- getPresetRange,
35
- DateRangePreset,
36
15
  Card,
37
16
  CardContent,
38
17
  CardHeader,
39
18
  CardTitle,
40
19
  } from "@checkstack/ui";
41
- import { formatDistanceToNow } from "date-fns";
42
- import {
43
- ChevronDown,
44
- ChevronRight,
45
- Satellite as SatelliteIcon,
46
- Server,
47
- } from "lucide-react";
48
- import { useNavigate } from "react-router-dom";
20
+ import { Heart } from "lucide-react";
49
21
  import { HealthCheckSparkline } from "./HealthCheckSparkline";
50
- import { HealthCheckLatencyChart } from "./HealthCheckLatencyChart";
51
- import { HealthCheckStatusTimeline } from "./HealthCheckStatusTimeline";
52
- import { useHealthCheckData } from "../hooks/useHealthCheckData";
22
+ import { HealthCheckDrawer } from "./HealthCheckDrawer";
53
23
 
54
24
  import type {
55
25
  StateThresholds,
56
26
  HealthCheckStatus,
57
27
  } from "@checkstack/healthcheck-common";
58
- import { AggregatedDataBanner } from "./AggregatedDataBanner";
59
- import { HealthCheckDiagramSlot } from "../slots";
60
28
 
61
29
  type SlotProps = SlotContext<typeof SystemDetailsSlot>;
62
30
 
31
+ /**
32
+ * Compact relative time formatter that prevents layout shift.
33
+ * Returns fixed-width strings like "< 1m", "5m", "2h", "3d".
34
+ */
35
+ function formatCompactTime(date: Date | undefined): string {
36
+ if (!date) return "—";
37
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
38
+ if (seconds < 60) return "< 1m";
39
+ const minutes = Math.floor(seconds / 60);
40
+ if (minutes < 60) return `${minutes}m`;
41
+ const hours = Math.floor(minutes / 60);
42
+ if (hours < 24) return `${hours}h`;
43
+ const days = Math.floor(hours / 24);
44
+ return `${days}d`;
45
+ }
46
+
63
47
  interface HealthCheckOverviewItem {
64
48
  configurationId: string;
65
49
  strategyId: string;
@@ -71,386 +55,13 @@ interface HealthCheckOverviewItem {
71
55
  recentStatusHistory: HealthCheckStatus[];
72
56
  }
73
57
 
74
- interface ExpandedRowProps {
75
- item: HealthCheckOverviewItem;
76
- systemId: string;
77
- }
78
-
79
- const ExpandedDetails: React.FC<ExpandedRowProps> = ({ item, systemId }) => {
80
- const healthCheckClient = usePluginClient(HealthCheckApi);
81
- const satelliteClient = usePluginClient(SatelliteApi);
82
- const navigate = useNavigate();
83
- const accessApi = useApi(accessApiRef);
84
- const { allowed: canViewDetails } = accessApi.useAccess(
85
- healthCheckAccess.details,
86
- );
87
- const { allowed: canReadSatellites } = accessApi.useAccess(
88
- satelliteAccess.satellite.read,
89
- );
90
-
91
- // Fetch satellites for source filter (only if user has access)
92
- const { data: satellitesData } = satelliteClient.listSatellites.useQuery(
93
- {},
94
- { enabled: canReadSatellites },
95
- );
96
- const satellites = satellitesData?.satellites ?? [];
97
-
98
- // Date range state for filtering - default to last 24 hours
99
- const [dateRange, setDateRange] = useState(() =>
100
- getPresetRange(DateRangePreset.Last24Hours),
101
- );
102
- // Track if a rolling preset is active (vs custom range)
103
- const [isRollingPreset, setIsRollingPreset] = useState(true);
104
- const [sourceFilter, setSourceFilter] = useState<string | undefined>();
105
-
106
- // Callback to handle date range changes from the filter
107
- const handleDateRangeChange = useCallback(
108
- (newRange: { startDate: Date; endDate: Date }) => {
109
- setDateRange(newRange);
110
- // Check if this is a rolling preset by comparing endDate to now (within 1 minute)
111
- const isNearNow =
112
- Math.abs(newRange.endDate.getTime() - Date.now()) < 60_000;
113
- setIsRollingPreset(isNearNow);
114
- // Clear any pending custom range when preset is selected
115
- setPendingCustomRange(undefined);
116
- },
117
- [],
118
- );
119
-
120
- // Local state for custom date picker - only applied when user clicks Apply
121
- const [pendingCustomRange, setPendingCustomRange] = useState<
122
- | {
123
- startDate: Date;
124
- endDate: Date;
125
- }
126
- | undefined
127
- >();
128
-
129
- // Handle custom date changes - store locally until Apply
130
- const handleCustomDateChange = useCallback(
131
- (newRange: { startDate: Date; endDate: Date }) => {
132
- setPendingCustomRange(newRange);
133
- },
134
- [],
135
- );
136
-
137
- // Apply pending custom range
138
- const handleApplyCustomRange = useCallback(() => {
139
- if (pendingCustomRange) {
140
- setDateRange(pendingCustomRange);
141
- setIsRollingPreset(false);
142
- setPendingCustomRange(undefined);
143
- }
144
- }, [pendingCustomRange]);
145
-
146
- // Use shared hook for chart data - handles both raw and aggregated modes
147
- // and includes signal handling for automatic refresh
148
- const {
149
- context: chartContext,
150
- loading: chartLoading,
151
- isFetching: chartFetching,
152
- bucketIntervalSeconds,
153
- } = useHealthCheckData({
154
- systemId,
155
- configurationId: item.configurationId,
156
- strategyId: item.strategyId,
157
- dateRange,
158
- sourceFilter,
159
- isRollingPreset,
160
- // Update endDate to current time when new runs are detected (only for rolling presets)
161
- onDateRangeRefresh: (newEndDate) => {
162
- setDateRange((prev) => ({ ...prev, endDate: newEndDate }));
163
- },
164
- });
165
-
166
- // Pagination state for history table
167
- const pagination = usePagination({ defaultLimit: 10 });
168
-
169
- // Fetch paginated history with useQuery - newest first for table
170
- const {
171
- data: historyData,
172
- isLoading: loading,
173
- refetch,
174
- } = healthCheckClient.getHistory.useQuery({
175
- systemId,
176
- configurationId: item.configurationId,
177
- limit: pagination.limit,
178
- offset: pagination.offset,
179
- startDate: dateRange.startDate,
180
- // Don't pass endDate - backend defaults to 'now' so new runs are included
181
- sourceFilter,
182
- sortOrder: "desc",
183
- });
184
-
185
- // Sync total from response
186
- usePaginationSync(pagination, historyData?.total);
187
-
188
- // Preserve previous runs during loading to prevent layout shift
189
- const prevRunsRef = useRef(historyData?.runs ?? []);
190
- const rawRuns = historyData?.runs ?? [];
191
- const displayRuns =
192
- loading && prevRunsRef.current.length > 0 ? prevRunsRef.current : rawRuns;
193
- if (!loading && rawRuns.length > 0) {
194
- prevRunsRef.current = rawRuns;
195
- }
196
- const runs = displayRuns;
197
-
198
- // Listen for realtime health check updates to refresh history table
199
- // Charts are refreshed automatically by useHealthCheckData
200
- useSignal(HEALTH_CHECK_RUN_COMPLETED, ({ systemId: changedId }) => {
201
- if (changedId === systemId) {
202
- void refetch();
203
- }
204
- });
205
-
206
- const thresholdDescription = item.stateThresholds
207
- ? item.stateThresholds.mode === "consecutive"
208
- ? `Consecutive mode: Healthy after ${item.stateThresholds.healthy.minSuccessCount} success(es), Degraded after ${item.stateThresholds.degraded.minFailureCount} failure(s), Unhealthy after ${item.stateThresholds.unhealthy.minFailureCount} failure(s)`
209
- : `Window mode (${item.stateThresholds.windowSize} runs): Degraded at ${item.stateThresholds.degraded.minFailureCount}+ failures, Unhealthy at ${item.stateThresholds.unhealthy.minFailureCount}+ failures`
210
- : "Using default thresholds";
211
-
212
- // Render charts - charts handle data transformation internally
213
- const renderCharts = () => {
214
- if (chartLoading) {
215
- return <LoadingSpinner />;
216
- }
217
-
218
- if (!chartContext) {
219
- return;
220
- }
221
-
222
- // Check if we have data to show
223
- const hasData = chartContext.buckets.length > 0;
224
-
225
- if (!hasData) {
226
- return;
227
- }
228
-
229
- return (
230
- <div className="space-y-4">
231
- {bucketIntervalSeconds && (
232
- <AggregatedDataBanner
233
- bucketIntervalSeconds={bucketIntervalSeconds}
234
- checkIntervalSeconds={item.intervalSeconds}
235
- />
236
- )}
237
- {/* Status Timeline */}
238
- <Card>
239
- <CardHeader className="pb-2">
240
- <CardTitle className="text-sm font-medium">
241
- Status Timeline
242
- </CardTitle>
243
- </CardHeader>
244
- <CardContent>
245
- <HealthCheckStatusTimeline context={chartContext} height={50} />
246
- </CardContent>
247
- </Card>
248
- {/* Execution Duration Chart */}
249
- <Card>
250
- <CardHeader className="pb-2">
251
- <CardTitle className="text-sm font-medium">
252
- Average Execution Duration
253
- </CardTitle>
254
- </CardHeader>
255
- <CardContent>
256
- <HealthCheckLatencyChart
257
- context={chartContext}
258
- height={150}
259
- showAverage
260
- />
261
- </CardContent>
262
- </Card>
263
- {/* Extension Slot for custom strategy-specific diagrams */}
264
- <ExtensionSlot slot={HealthCheckDiagramSlot} context={chartContext} />
265
- </div>
266
- );
267
- };
268
-
269
- return (
270
- <div className="p-4 bg-muted/30 border-t space-y-4">
271
- <div className="flex flex-wrap gap-4 text-sm">
272
- <div>
273
- <span className="text-muted-foreground">Strategy:</span>{" "}
274
- <span className="font-medium">{item.strategyId}</span>
275
- </div>
276
- <div>
277
- <span className="text-muted-foreground">Interval:</span>{" "}
278
- <span className="font-medium">{item.intervalSeconds}s</span>
279
- </div>
280
- <div className="flex items-center gap-1">
281
- <span className="text-muted-foreground">Thresholds:</span>{" "}
282
- <Tooltip content={thresholdDescription} />
283
- </div>
284
- </div>
285
-
286
- {/* Date Range Filter with Loading Spinner */}
287
- <div className="flex items-center gap-3 flex-wrap">
288
- <DateRangeFilter
289
- value={pendingCustomRange ?? dateRange}
290
- onChange={handleDateRangeChange}
291
- onCustomChange={handleCustomDateChange}
292
- disabled={chartFetching}
293
- />
294
- {pendingCustomRange && (
295
- <button
296
- onClick={handleApplyCustomRange}
297
- disabled={
298
- chartFetching ||
299
- pendingCustomRange.startDate >= pendingCustomRange.endDate
300
- }
301
- className="px-3 py-1.5 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
302
- >
303
- Apply
304
- </button>
305
- )}
306
- {chartFetching && (
307
- <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
308
- )}
309
- </div>
310
- {/* Source filter (visible when satellites exist and user has read access) */}
311
- {canReadSatellites && satellites.length > 0 && (
312
- <div className="flex items-center gap-2">
313
- <span className="text-xs text-muted-foreground">Source:</span>
314
- <div className="flex items-center gap-1">
315
- <button
316
- onClick={() => setSourceFilter(undefined)}
317
- className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full transition-colors ${
318
- sourceFilter === undefined
319
- ? "bg-primary text-primary-foreground"
320
- : "bg-muted text-muted-foreground hover:bg-muted/80"
321
- }`}
322
- >
323
- All
324
- </button>
325
- <button
326
- onClick={() => setSourceFilter("local")}
327
- className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full transition-colors ${
328
- sourceFilter === "local"
329
- ? "bg-primary text-primary-foreground"
330
- : "bg-muted text-muted-foreground hover:bg-muted/80"
331
- }`}
332
- >
333
- <Server className="h-3 w-3" />
334
- Local
335
- </button>
336
- {satellites.map((sat) => (
337
- <button
338
- key={sat.id}
339
- onClick={() => setSourceFilter(sat.id)}
340
- className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full transition-colors ${
341
- sourceFilter === sat.id
342
- ? "bg-orange-500 text-white"
343
- : "bg-orange-500/10 text-orange-600 hover:bg-orange-500/20"
344
- }`}
345
- >
346
- <SatelliteIcon className="h-3 w-3" />
347
- {sat.name}
348
- </button>
349
- ))}
350
- </div>
351
- </div>
352
- )}
353
-
354
- {/* Charts Section */}
355
- {renderCharts()}
356
-
357
- {loading && prevRunsRef.current.length === 0 ? (
358
- <LoadingSpinner />
359
- ) : runs.length > 0 ? (
360
- <>
361
- {/* Divider between charts and table */}
362
- <div className="flex items-center gap-4 pt-4">
363
- <div className="flex-1 h-px bg-border" />
364
- <span className="text-sm text-muted-foreground flex items-center gap-2">
365
- Recent Runs
366
- {loading && <Loader2 className="h-3 w-3 animate-spin" />}
367
- </span>
368
- <div className="flex-1 h-px bg-border" />
369
- </div>
370
-
371
- <div className="rounded-md border">
372
- <Table>
373
- <TableHeader>
374
- <TableRow>
375
- <TableHead className="w-24">Status</TableHead>
376
- <TableHead>Time</TableHead>
377
- <TableHead>Source</TableHead>
378
- </TableRow>
379
- </TableHeader>
380
- <TableBody>
381
- {runs.map((run) => (
382
- <TableRow
383
- key={run.id}
384
- className={`${
385
- canViewDetails ? "cursor-pointer hover:bg-muted/50" : ""
386
- } ${loading ? "opacity-50" : ""}`}
387
- onClick={
388
- canViewDetails
389
- ? () =>
390
- navigate(
391
- resolveRoute(
392
- healthcheckRoutes.routes.historyRun,
393
- {
394
- systemId,
395
- configurationId: item.configurationId,
396
- runId: run.id,
397
- },
398
- ),
399
- )
400
- : undefined
401
- }
402
- >
403
- <TableCell>
404
- <HealthBadge status={run.status} />
405
- </TableCell>
406
- <TableCell className="text-muted-foreground">
407
- {formatDistanceToNow(new Date(run.timestamp), {
408
- addSuffix: true,
409
- })}
410
- </TableCell>
411
- <TableCell>
412
- {run.sourceId ? (
413
- <span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-orange-500/10 text-orange-600">
414
- <SatelliteIcon className="h-3 w-3" />
415
- {run.sourceLabel ?? "Remote"}
416
- </span>
417
- ) : (
418
- <span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
419
- <Server className="h-3 w-3" />
420
- {run.sourceLabel ?? "Local"}
421
- </span>
422
- )}
423
- </TableCell>
424
- </TableRow>
425
- ))}
426
- </TableBody>
427
- </Table>
428
- </div>
429
- <Pagination
430
- page={pagination.page}
431
- totalPages={pagination.totalPages}
432
- onPageChange={pagination.setPage}
433
- total={pagination.total}
434
- limit={pagination.limit}
435
- onPageSizeChange={pagination.setLimit}
436
- showPageSize
437
- showTotal
438
- />
439
- </>
440
- ) : (
441
- <div className="text-center text-muted-foreground py-4">
442
- No runs recorded yet
443
- </div>
444
- )}
445
- </div>
446
- );
447
- };
448
-
449
58
  export function HealthCheckSystemOverview(props: SlotProps) {
450
59
  const systemId = props.system.id;
451
60
  const healthCheckClient = usePluginClient(HealthCheckApi);
452
61
 
453
- const [expandedRow, setExpandedRow] = React.useState<string | undefined>();
62
+ const [selectedCheck, setSelectedCheck] = useState<
63
+ HealthCheckOverviewItem | undefined
64
+ >();
454
65
 
455
66
  // Fetch health check overview using useQuery
456
67
  const {
@@ -496,59 +107,68 @@ export function HealthCheckSystemOverview(props: SlotProps) {
496
107
  }
497
108
 
498
109
  if (overview.length === 0) {
499
- return (
500
- <div className="text-center text-muted-foreground py-4">
501
- No health checks configured
502
- </div>
503
- );
110
+ return;
504
111
  }
505
112
 
506
113
  return (
507
- <div className="space-y-2">
508
- {overview.map((item) => {
509
- const isExpanded = expandedRow === item.configurationId;
510
-
511
- return (
512
- <div key={item.configurationId} className="rounded-md border bg-card">
513
- <button
514
- className="w-full p-4 text-left hover:bg-muted/50 transition-colors"
515
- onClick={() =>
516
- setExpandedRow(isExpanded ? undefined : item.configurationId)
517
- }
518
- >
519
- {/* Header row: chevron, name, badge */}
520
- <div className="flex items-center gap-3">
521
- {isExpanded ? (
522
- <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
523
- ) : (
524
- <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
525
- )}
526
- <div className="flex-1 min-w-0 flex items-center justify-between gap-2">
527
- <span className="font-medium truncate">{item.name}</span>
528
- <HealthBadge status={item.state} />
529
- </div>
530
- </div>
531
- {/* Details row: last run + sparkline */}
532
- <div className="ml-7 mt-1 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
533
- <span className="text-sm text-muted-foreground">
534
- Last run:{" "}
535
- {item.lastRunAt
536
- ? formatDistanceToNow(item.lastRunAt, { addSuffix: true })
537
- : "never"}
114
+ <>
115
+ <Card>
116
+ <CardHeader className="pb-3">
117
+ <div className="flex items-center gap-2">
118
+ <Heart className="h-4 w-4 text-muted-foreground" />
119
+ <CardTitle className="text-base font-semibold">
120
+ Health Checks
121
+ </CardTitle>
122
+ </div>
123
+ </CardHeader>
124
+ <CardContent className="p-0">
125
+ <div className="divide-y divide-border">
126
+ {overview.map((item) => (
127
+ <button
128
+ key={item.configurationId}
129
+ className="w-full px-4 py-3 text-left hover:bg-muted/50 transition-colors flex items-center gap-3"
130
+ onClick={() => setSelectedCheck(item)}
131
+ >
132
+ {/* Check name */}
133
+ <span className="font-medium truncate flex-1 min-w-0 text-sm">
134
+ {item.name}
538
135
  </span>
136
+
137
+ {/* Status badge */}
138
+ <HealthBadge status={item.state} />
139
+
140
+ {/* Sparkline */}
539
141
  {item.recentStatusHistory.length > 0 && (
540
- <HealthCheckSparkline
541
- runs={item.recentStatusHistory.map((status) => ({
542
- status,
543
- }))}
544
- />
142
+ <div className="hidden sm:block shrink-0">
143
+ <HealthCheckSparkline
144
+ runs={item.recentStatusHistory.map((status) => ({
145
+ status,
146
+ }))}
147
+ />
148
+ </div>
545
149
  )}
546
- </div>
547
- </button>
548
- {isExpanded && <ExpandedDetails item={item} systemId={systemId} />}
150
+
151
+ {/* Last run — compact fixed-width to prevent shift */}
152
+ <span className="hidden md:block text-xs text-muted-foreground w-10 text-right shrink-0 tabular-nums">
153
+ {formatCompactTime(item.lastRunAt)}
154
+ </span>
155
+ </button>
156
+ ))}
549
157
  </div>
550
- );
551
- })}
552
- </div>
158
+ </CardContent>
159
+ </Card>
160
+
161
+ {/* Slide-over Drawer */}
162
+ {selectedCheck && (
163
+ <HealthCheckDrawer
164
+ item={selectedCheck}
165
+ systemId={systemId}
166
+ open={!!selectedCheck}
167
+ onOpenChange={(open) => {
168
+ if (!open) setSelectedCheck(undefined);
169
+ }}
170
+ />
171
+ )}
172
+ </>
553
173
  );
554
174
  }
@@ -35,7 +35,7 @@ export const SystemHealthBadge: React.FC<Props> = ({ system }) => {
35
35
  {
36
36
  enabled: !badgeData && !!system?.id,
37
37
  staleTime: 30_000, // Prevent unnecessary refetches
38
- }
38
+ },
39
39
  );
40
40
 
41
41
  const localStatus = healthData?.status;
@@ -50,6 +50,6 @@ export const SystemHealthBadge: React.FC<Props> = ({ system }) => {
50
50
  // Use provider data if available, otherwise use local state
51
51
  const status = providerStatus ?? localStatus;
52
52
 
53
- if (!status) return <></>;
53
+ if (!status || status === "healthy") return <></>;
54
54
  return <HealthBadge status={status} />;
55
55
  };
@@ -25,6 +25,7 @@ interface AssignmentTreeProps {
25
25
  selectedNode: AssignmentNodeId | undefined;
26
26
  onSelectNode: (nodeId: AssignmentNodeId) => void;
27
27
  onToggleAssignment: (configId: string, assigned: boolean) => void;
28
+ isLocked?: boolean;
28
29
  }
29
30
 
30
31
  // =============================================================================
@@ -37,6 +38,7 @@ export const AssignmentTree: React.FC<AssignmentTreeProps> = ({
37
38
  selectedNode,
38
39
  onSelectNode,
39
40
  onToggleAssignment,
41
+ isLocked,
40
42
  }) => {
41
43
  return (
42
44
  <div className="py-2">
@@ -99,7 +101,7 @@ export const AssignmentTree: React.FC<AssignmentTreeProps> = ({
99
101
  ))}
100
102
 
101
103
  {/* Available (unassigned) health checks */}
102
- {available.length > 0 && (
104
+ {!isLocked && available.length > 0 && (
103
105
  <>
104
106
  <IDETreeSection label="Available" />
105
107
  {available.map((config) => (
@@ -16,6 +16,7 @@ interface ExecutionPanelProps {
16
16
  onToggleLocal: () => void;
17
17
  onToggleSatellite: (satelliteId: string) => void;
18
18
  saving: boolean;
19
+ isLocked?: boolean;
19
20
  }
20
21
 
21
22
  /**
@@ -29,6 +30,7 @@ export const ExecutionPanel: React.FC<ExecutionPanelProps> = ({
29
30
  onToggleLocal,
30
31
  onToggleSatellite,
31
32
  saving,
33
+ isLocked,
32
34
  }) => {
33
35
  const hasSatellites = satelliteIds.length > 0;
34
36
  const willRunAnywhere = includeLocal || hasSatellites;
@@ -49,7 +51,7 @@ export const ExecutionPanel: React.FC<ExecutionPanelProps> = ({
49
51
  <Checkbox
50
52
  checked={includeLocal}
51
53
  onCheckedChange={onToggleLocal}
52
- disabled={saving || (!hasSatellites && includeLocal)}
54
+ disabled={saving || isLocked || (!hasSatellites && includeLocal)}
53
55
  />
54
56
  <div>
55
57
  <Label className="text-sm font-medium">Run Locally</Label>
@@ -87,7 +89,7 @@ export const ExecutionPanel: React.FC<ExecutionPanelProps> = ({
87
89
  <Checkbox
88
90
  checked={isChecked}
89
91
  onCheckedChange={() => onToggleSatellite(sat.id)}
90
- disabled={saving}
92
+ disabled={saving || isLocked}
91
93
  />
92
94
  <div className="flex-1 min-w-0">
93
95
  <div className="flex items-center gap-2">