@checkstack/healthcheck-frontend 0.17.1 → 0.18.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.
@@ -7,9 +7,7 @@ import {
7
7
  useApi,
8
8
  accessApiRef,
9
9
  } from "@checkstack/frontend-api";
10
- import { useSignal } from "@checkstack/signal-frontend";
11
10
  import {
12
- HEALTH_CHECK_RUN_COMPLETED,
13
11
  HealthCheckApi,
14
12
  healthCheckAccess,
15
13
  healthcheckRoutes,
@@ -199,11 +197,8 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
199
197
  // Pagination for history table
200
198
  const pagination = usePagination({ defaultLimit: 5 });
201
199
 
202
- const {
203
- data: historyData,
204
- isLoading: historyLoading,
205
- refetch,
206
- } = healthCheckClient.getHistory.useQuery({
200
+ const { data: historyData, isLoading: historyLoading } =
201
+ healthCheckClient.getHistory.useQuery({
207
202
  systemId,
208
203
  configurationId: item.configurationId,
209
204
  limit: pagination.limit,
@@ -226,13 +221,6 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
226
221
  }
227
222
  const runs = displayRuns;
228
223
 
229
- // Realtime updates
230
- useSignal(HEALTH_CHECK_RUN_COMPLETED, ({ systemId: changedId }) => {
231
- if (changedId === systemId) {
232
- void refetch();
233
- }
234
- });
235
-
236
224
  return (
237
225
  <Sheet open={open} onOpenChange={onOpenChange}>
238
226
  <SheetContent size="lg">
@@ -417,7 +405,7 @@ export const HealthCheckDrawer: React.FC<HealthCheckDrawerProps> = ({
417
405
  <CardContent>
418
406
  <HealthCheckStatusTimeline
419
407
  context={chartContext}
420
- height={40}
408
+ height={96}
421
409
  />
422
410
  </CardContent>
423
411
  </Card>
@@ -1,6 +1,14 @@
1
1
  import { format } from "date-fns";
2
+ import { usePerformance } from "@checkstack/ui";
3
+ import {
4
+ Bar,
5
+ BarChart,
6
+ ResponsiveContainer,
7
+ Tooltip,
8
+ XAxis,
9
+ YAxis,
10
+ } from "recharts";
2
11
  import type { HealthCheckDiagramSlotContext } from "../slots";
3
- import { SparklineTooltip } from "./SparklineTooltip";
4
12
 
5
13
  interface HealthCheckStatusTimelineProps {
6
14
  context: HealthCheckDiagramSlotContext;
@@ -13,19 +21,114 @@ const statusColors = {
13
21
  unhealthy: "hsl(var(--destructive))",
14
22
  };
15
23
 
24
+ interface TimelineDatum {
25
+ bucketStart: number;
26
+ bucketEnd: number;
27
+ healthy: number;
28
+ degraded: number;
29
+ unhealthy: number;
30
+ runCount: number;
31
+ }
32
+
33
+ const MAX_TIMELINE_BARS = 60;
34
+
35
+ function downsampleTimeline(
36
+ buckets: HealthCheckDiagramSlotContext["buckets"],
37
+ ): TimelineDatum[] {
38
+ if (buckets.length === 0) return [];
39
+
40
+ const groupSize = Math.max(1, Math.ceil(buckets.length / MAX_TIMELINE_BARS));
41
+ const result: TimelineDatum[] = [];
42
+
43
+ for (let i = 0; i < buckets.length; i += groupSize) {
44
+ const slice = buckets.slice(i, i + groupSize);
45
+ const first = slice[0];
46
+ const last = slice.at(-1) ?? first;
47
+ let healthy = 0;
48
+ let degraded = 0;
49
+ let unhealthy = 0;
50
+ let runCount = 0;
51
+ for (const b of slice) {
52
+ healthy += b.healthyCount;
53
+ degraded += b.degradedCount;
54
+ unhealthy += b.unhealthyCount;
55
+ runCount += b.runCount;
56
+ }
57
+ result.push({
58
+ bucketStart: new Date(first.bucketStart).getTime(),
59
+ bucketEnd: new Date(last.bucketEnd).getTime(),
60
+ healthy,
61
+ degraded,
62
+ unhealthy,
63
+ runCount,
64
+ });
65
+ }
66
+
67
+ return result;
68
+ }
69
+
70
+ function pickTickFormat(intervalSeconds: number): string {
71
+ if (intervalSeconds >= 86_400) return "MMM d";
72
+ if (intervalSeconds >= 3600) return "MMM d HH:mm";
73
+ return "HH:mm";
74
+ }
75
+
76
+ function StatusTimelineTooltip({
77
+ active,
78
+ payload,
79
+ }: {
80
+ active?: boolean;
81
+ payload?: { payload: TimelineDatum }[];
82
+ }) {
83
+ if (!active || !payload?.length) return <></>;
84
+ const datum = payload[0].payload;
85
+ const span = `${format(new Date(datum.bucketStart), "MMM d, HH:mm")} – ${format(new Date(datum.bucketEnd), "HH:mm")}`;
86
+ return (
87
+ <div
88
+ className="rounded-md p-2 text-xs shadow-md"
89
+ style={{
90
+ backgroundColor: "hsl(var(--popover))",
91
+ border: "1px solid hsl(var(--border))",
92
+ color: "hsl(var(--popover-foreground))",
93
+ }}
94
+ >
95
+ <p className="text-muted-foreground mb-1">{span}</p>
96
+ <div className="space-y-0.5">
97
+ <p>
98
+ <span style={{ color: statusColors.healthy }}>●</span> Healthy:{" "}
99
+ {datum.healthy}
100
+ </p>
101
+ {datum.degraded > 0 && (
102
+ <p>
103
+ <span style={{ color: statusColors.degraded }}>●</span> Degraded:{" "}
104
+ {datum.degraded}
105
+ </p>
106
+ )}
107
+ {datum.unhealthy > 0 && (
108
+ <p>
109
+ <span style={{ color: statusColors.unhealthy }}>●</span> Unhealthy:{" "}
110
+ {datum.unhealthy}
111
+ </p>
112
+ )}
113
+ </div>
114
+ </div>
115
+ );
116
+ }
117
+
16
118
  /**
17
119
  * Timeline bar chart showing health check status distribution over time.
18
- * Each bar shows the distribution of statuses in that aggregated bucket.
120
+ * Each bar shows the stacked distribution of statuses in that aggregated bucket.
19
121
  */
20
122
  export const HealthCheckStatusTimeline: React.FC<
21
123
  HealthCheckStatusTimelineProps
22
- > = ({ context, height = 60 }) => {
23
- const buckets = context.buckets;
124
+ > = ({ context, height = 96 }) => {
125
+ const { isLowPower } = usePerformance();
126
+ const { buckets } = context;
24
127
 
25
128
  if (buckets.length === 0) {
26
129
  return (
27
130
  <div
28
- className="flex items-center justify-center text-muted-foreground"
131
+ className="flex items-center justify-center text-muted-foreground text-sm"
29
132
  style={{ height }}
30
133
  >
31
134
  No status data available
@@ -33,75 +136,64 @@ export const HealthCheckStatusTimeline: React.FC<
33
136
  );
34
137
  }
35
138
 
36
- // Use daily format for intervals >= 6 hours, otherwise include time
37
- const timeFormat =
38
- (buckets[0]?.bucketIntervalSeconds ?? 3600) >= 21_600
39
- ? "MMM d"
40
- : "MMM d HH:mm";
41
-
42
- // Calculate time range for labels
43
- const firstTime = new Date(buckets[0].bucketStart).getTime();
44
- const lastTime = new Date(buckets.at(-1)!.bucketStart).getTime();
139
+ const data = downsampleTimeline(buckets);
140
+ const effectiveIntervalSeconds =
141
+ (buckets[0]?.bucketIntervalSeconds ?? 3600) *
142
+ Math.max(1, Math.ceil(buckets.length / MAX_TIMELINE_BARS));
143
+ const tickFormat = pickTickFormat(effectiveIntervalSeconds);
45
144
 
46
145
  return (
47
- <div style={{ height }} className="flex flex-col justify-between">
48
- {/* Status strip - equal width stacked segments for each bucket */}
49
- <div className="flex h-4 gap-px rounded-md bg-muted/30">
50
- {buckets.map((bucket, index) => {
51
- const total = bucket.runCount || 1;
52
- const healthyPct = (bucket.healthyCount / total) * 100;
53
- const degradedPct = (bucket.degradedCount / total) * 100;
54
- const unhealthyPct = (bucket.unhealthyCount / total) * 100;
55
-
56
- // Use actual bucket end time from response (critical for last bucket which extends to query end)
57
- const bucketStart = new Date(bucket.bucketStart);
58
- const bucketEnd = new Date(bucket.bucketEnd);
59
- const timeSpan = `${format(bucketStart, "MMM d, HH:mm")} - ${format(bucketEnd, "HH:mm")}`;
60
-
61
- return (
62
- <SparklineTooltip
63
- key={index}
64
- content={`${timeSpan}\nHealthy: ${bucket.healthyCount}\nDegraded: ${bucket.degradedCount}\nUnhealthy: ${bucket.unhealthyCount}`}
65
- >
66
- <div className="flex-1 h-full flex flex-col cursor-pointer group">
67
- {bucket.healthyCount > 0 && (
68
- <div
69
- className="w-full transition-opacity group-hover:opacity-80"
70
- style={{
71
- height: `${healthyPct}%`,
72
- backgroundColor: statusColors.healthy,
73
- }}
74
- />
75
- )}
76
- {bucket.degradedCount > 0 && (
77
- <div
78
- className="w-full transition-opacity group-hover:opacity-80"
79
- style={{
80
- height: `${degradedPct}%`,
81
- backgroundColor: statusColors.degraded,
82
- }}
83
- />
84
- )}
85
- {bucket.unhealthyCount > 0 && (
86
- <div
87
- className="w-full transition-opacity group-hover:opacity-80"
88
- style={{
89
- height: `${unhealthyPct}%`,
90
- backgroundColor: statusColors.unhealthy,
91
- }}
92
- />
93
- )}
94
- </div>
95
- </SparklineTooltip>
96
- );
97
- })}
98
- </div>
99
-
100
- {/* Time axis labels */}
101
- <div className="flex justify-between text-xs text-muted-foreground mt-1">
102
- <span>{format(new Date(firstTime), timeFormat)}</span>
103
- <span>{format(new Date(lastTime), timeFormat)}</span>
104
- </div>
146
+ <div style={{ height, width: "100%" }}>
147
+ <ResponsiveContainer width="100%" height="100%">
148
+ <BarChart
149
+ data={data}
150
+ margin={{ top: 4, right: 4, left: 4, bottom: 0 }}
151
+ barCategoryGap={1}
152
+ >
153
+ <XAxis
154
+ dataKey="bucketStart"
155
+ tickFormatter={(value: number) => format(new Date(value), tickFormat)}
156
+ stroke="hsl(var(--muted-foreground))"
157
+ fontSize={11}
158
+ minTickGap={48}
159
+ tickLine={false}
160
+ axisLine={false}
161
+ interval="preserveStartEnd"
162
+ />
163
+ <YAxis hide domain={[0, "dataMax"]} />
164
+ <Tooltip
165
+ cursor={{ fill: "hsl(var(--muted))", fillOpacity: 0.3 }}
166
+ content={(props) => (
167
+ <StatusTimelineTooltip
168
+ active={props.active}
169
+ payload={
170
+ props.payload as unknown as
171
+ | { payload: TimelineDatum }[]
172
+ | undefined
173
+ }
174
+ />
175
+ )}
176
+ />
177
+ <Bar
178
+ dataKey="healthy"
179
+ stackId="status"
180
+ fill={statusColors.healthy}
181
+ isAnimationActive={!isLowPower}
182
+ />
183
+ <Bar
184
+ dataKey="degraded"
185
+ stackId="status"
186
+ fill={statusColors.degraded}
187
+ isAnimationActive={!isLowPower}
188
+ />
189
+ <Bar
190
+ dataKey="unhealthy"
191
+ stackId="status"
192
+ fill={statusColors.unhealthy}
193
+ isAnimationActive={!isLowPower}
194
+ />
195
+ </BarChart>
196
+ </ResponsiveContainer>
105
197
  </div>
106
198
  );
107
199
  };
@@ -1,14 +1,10 @@
1
- import React, { useState, useCallback } from "react";
1
+ import React, { useState } from "react";
2
2
  import {
3
3
  usePluginClient,
4
4
  type SlotContext,
5
5
  } from "@checkstack/frontend-api";
6
- import { useSignal } from "@checkstack/signal-frontend";
7
6
  import { SystemDetailsSlot } from "@checkstack/catalog-common";
8
- import {
9
- HEALTH_CHECK_RUN_COMPLETED,
10
- HealthCheckApi,
11
- } from "@checkstack/healthcheck-common";
7
+ import { HealthCheckApi } from "@checkstack/healthcheck-common";
12
8
  import {
13
9
  HealthBadge,
14
10
  LoadingSpinner,
@@ -63,14 +59,11 @@ export function HealthCheckSystemOverview(props: SlotProps) {
63
59
  HealthCheckOverviewItem | undefined
64
60
  >();
65
61
 
66
- // Fetch health check overview using useQuery
67
- const {
68
- data: overviewData,
69
- isLoading: initialLoading,
70
- refetch,
71
- } = healthCheckClient.getSystemHealthOverview.useQuery({
72
- systemId,
73
- });
62
+ // Fetch health check overview using useQuery — kept fresh via SignalAutoInvalidator.
63
+ const { data: overviewData, isLoading: initialLoading } =
64
+ healthCheckClient.getSystemHealthOverview.useQuery({
65
+ systemId,
66
+ });
74
67
 
75
68
  // Transform API response to component format
76
69
  const overview: HealthCheckOverviewItem[] = React.useMemo(() => {
@@ -89,19 +82,6 @@ export function HealthCheckSystemOverview(props: SlotProps) {
89
82
  }));
90
83
  }, [overviewData]);
91
84
 
92
- // Listen for realtime health check updates to refresh overview
93
- useSignal(
94
- HEALTH_CHECK_RUN_COMPLETED,
95
- useCallback(
96
- ({ systemId: changedId }) => {
97
- if (changedId === systemId) {
98
- void refetch();
99
- }
100
- },
101
- [systemId, refetch],
102
- ),
103
- );
104
-
105
85
  if (initialLoading) {
106
86
  return <LoadingSpinner />;
107
87
  }
@@ -1,8 +1,6 @@
1
1
  import React from "react";
2
2
  import { usePluginClient, type SlotContext } from "@checkstack/frontend-api";
3
- import { useSignal } from "@checkstack/signal-frontend";
4
3
  import { SystemStateBadgesSlot } from "@checkstack/catalog-common";
5
- import { SYSTEM_STATUS_CHANGED } from "@checkstack/healthcheck-common";
6
4
  import { HealthCheckApi } from "../api";
7
5
  import { HealthBadge } from "@checkstack/ui";
8
6
  import { useSystemBadgeDataOptional } from "@checkstack/dashboard-frontend";
@@ -17,37 +15,25 @@ type Props = SlotContext<typeof SystemStateBadgesSlot>;
17
15
  * When rendered within SystemBadgeDataProvider, uses bulk-fetched data.
18
16
  * Otherwise, falls back to individual fetch.
19
17
  *
20
- * Listens for realtime updates via signals.
18
+ * Realtime updates arrive via the SignalAutoInvalidator (auto-invalidates
19
+ * `[["healthcheck"]]` queries when SYSTEM_STATUS_CHANGED fires).
21
20
  */
22
21
  export const SystemHealthBadge: React.FC<Props> = ({ system }) => {
23
22
  const healthCheckClient = usePluginClient(HealthCheckApi);
24
23
  const badgeData = useSystemBadgeDataOptional();
25
24
 
26
- // Try to get data from provider first
27
25
  const providerData = badgeData?.getSystemBadgeData(system?.id ?? "");
28
26
  const providerStatus = providerData?.health?.status;
29
27
 
30
- // Query for health status if not using provider
31
- // When badgeData exists (inside provider), this query is disabled
32
- const { data: healthData, refetch } =
33
- healthCheckClient.getSystemHealthStatus.useQuery(
34
- { systemId: system?.id ?? "" },
35
- {
36
- enabled: !badgeData && !!system?.id,
37
- staleTime: 30_000, // Prevent unnecessary refetches
38
- },
39
- );
28
+ const { data: healthData } = healthCheckClient.getSystemHealthStatus.useQuery(
29
+ { systemId: system?.id ?? "" },
30
+ {
31
+ enabled: !badgeData && !!system?.id,
32
+ staleTime: 30_000,
33
+ },
34
+ );
40
35
 
41
36
  const localStatus = healthData?.status;
42
-
43
- // Listen for realtime system status changes (only in fallback mode)
44
- useSignal(SYSTEM_STATUS_CHANGED, ({ systemId: changedId }) => {
45
- if (!badgeData && changedId === system?.id) {
46
- void refetch();
47
- }
48
- });
49
-
50
- // Use provider data if available, otherwise use local state
51
37
  const status = providerStatus ?? localStatus;
52
38
 
53
39
  if (!status || status === "healthy") return <></>;
@@ -8,6 +8,7 @@ import type { TreeNodeId } from "./EditorTree";
8
8
  import { GeneralSection } from "./GeneralSection";
9
9
  import { CollectorSection } from "./CollectorSection";
10
10
  import { CollectorPicker } from "./CollectorPicker";
11
+ import { SystemsSection } from "./SystemsSection";
11
12
  import { TeamAccessEditor } from "@checkstack/auth-frontend";
12
13
 
13
14
  // =============================================================================
@@ -43,6 +44,11 @@ interface EditorPanelProps {
43
44
  onCollectorRemove: (entryId: string) => void;
44
45
  onCollectorAdd: (collectorId: string) => void;
45
46
  strategyId: string;
47
+ showSystemsSection?: boolean;
48
+ systems?: Array<{ id: string; name: string; description?: string | null }>;
49
+ systemsLoading?: boolean;
50
+ selectedSystemIds?: string[];
51
+ onSystemsChange?: (systemIds: string[]) => void;
46
52
  }
47
53
 
48
54
  // =============================================================================
@@ -67,6 +73,11 @@ export const EditorPanel: React.FC<EditorPanelProps> = ({
67
73
  onCollectorRemove,
68
74
  onCollectorAdd,
69
75
  strategyId,
76
+ showSystemsSection = false,
77
+ systems = [],
78
+ systemsLoading = false,
79
+ selectedSystemIds = [],
80
+ onSystemsChange,
70
81
  }) => {
71
82
  // --- General Section ---
72
83
  if (selectedNode === "general") {
@@ -101,6 +112,20 @@ export const EditorPanel: React.FC<EditorPanelProps> = ({
101
112
  );
102
113
  }
103
114
 
115
+ // --- Systems Section ---
116
+ if (selectedNode === "systems" && showSystemsSection) {
117
+ return (
118
+ <div className="p-6">
119
+ <SystemsSection
120
+ systems={systems}
121
+ selectedSystemIds={selectedSystemIds}
122
+ loading={systemsLoading}
123
+ onChange={(ids) => onSystemsChange?.(ids)}
124
+ />
125
+ </div>
126
+ );
127
+ }
128
+
104
129
  // --- Access Control ---
105
130
  if (selectedNode === "access") {
106
131
  return (
@@ -3,7 +3,7 @@ import type {
3
3
  CollectorConfigEntry,
4
4
  CollectorDto,
5
5
  } from "@checkstack/healthcheck-common";
6
- import { Plus, Settings, Shield, ChevronRight } from "lucide-react";
6
+ import { Plus, Settings, Shield, ChevronRight, Server } from "lucide-react";
7
7
  import { isBuiltInCollector } from "../../hooks/useCollectors";
8
8
  import {
9
9
  IDETreeNode,
@@ -20,6 +20,7 @@ import { HealthCheckConfigIDENodeSlot } from "../../slots";
20
20
  export type TreeNodeId =
21
21
  | "general"
22
22
  | "access"
23
+ | "systems"
23
24
  | "collector-picker"
24
25
  | `collector:${string}`
25
26
  | (string & {});
@@ -33,6 +34,8 @@ interface EditorTreeProps {
33
34
  validationIssues: ValidationIssue[];
34
35
  strategyId: string;
35
36
  configId?: string;
37
+ showSystemsNode?: boolean;
38
+ selectedSystemCount?: number;
36
39
  }
37
40
 
38
41
  // =============================================================================
@@ -47,6 +50,8 @@ export const EditorTree: React.FC<EditorTreeProps> = ({
47
50
  validationIssues,
48
51
  strategyId,
49
52
  configId,
53
+ showSystemsNode = false,
54
+ selectedSystemCount = 0,
50
55
  }) => {
51
56
  // Check if there are addable collectors remaining
52
57
  const hasAddableCollectors = useMemo(() => {
@@ -109,6 +114,23 @@ export const EditorTree: React.FC<EditorTreeProps> = ({
109
114
  </button>
110
115
  )}
111
116
 
117
+ {showSystemsNode && (
118
+ <>
119
+ <IDETreeSection label="Assignment" />
120
+ <IDETreeNode
121
+ nodeId="systems"
122
+ label="Systems"
123
+ icon={Server}
124
+ selected={selectedNode === "systems"}
125
+ onClick={() => onSelectNode("systems")}
126
+ issues={validationIssues}
127
+ badge={
128
+ selectedSystemCount > 0 ? String(selectedSystemCount) : undefined
129
+ }
130
+ />
131
+ </>
132
+ )}
133
+
112
134
  {/* Access Control */}
113
135
  <IDETreeSection label="Permissions" />
114
136
 
@@ -0,0 +1,126 @@
1
+ import React, { useMemo, useState } from "react";
2
+ import { Checkbox, Input, InfoBanner, InfoBannerContent } from "@checkstack/ui";
3
+ import { Info, Search } from "lucide-react";
4
+
5
+ interface SystemOption {
6
+ id: string;
7
+ name: string;
8
+ description?: string | null;
9
+ }
10
+
11
+ interface SystemsSectionProps {
12
+ systems: SystemOption[];
13
+ selectedSystemIds: string[];
14
+ loading: boolean;
15
+ onChange: (systemIds: string[]) => void;
16
+ }
17
+
18
+ export const SystemsSection: React.FC<SystemsSectionProps> = ({
19
+ systems,
20
+ selectedSystemIds,
21
+ loading,
22
+ onChange,
23
+ }) => {
24
+ const [query, setQuery] = useState("");
25
+
26
+ const selectedSet = useMemo(
27
+ () => new Set(selectedSystemIds),
28
+ [selectedSystemIds],
29
+ );
30
+
31
+ const filteredSystems = useMemo(() => {
32
+ const trimmed = query.trim().toLowerCase();
33
+ if (!trimmed) return systems;
34
+ return systems.filter((s) => s.name.toLowerCase().includes(trimmed));
35
+ }, [systems, query]);
36
+
37
+ const toggle = ({ systemId }: { systemId: string }) => {
38
+ if (selectedSet.has(systemId)) {
39
+ onChange(selectedSystemIds.filter((id) => id !== systemId));
40
+ } else {
41
+ onChange([...selectedSystemIds, systemId]);
42
+ }
43
+ };
44
+
45
+ return (
46
+ <div className="space-y-6">
47
+ <div>
48
+ <h2 className="text-lg font-semibold">Assign to systems</h2>
49
+ <p className="text-sm text-muted-foreground">
50
+ Optionally pick systems this check should monitor. You can also
51
+ assign it later from a system&apos;s catalog page.
52
+ </p>
53
+ </div>
54
+
55
+ <InfoBanner variant="info">
56
+ <Info className="h-4 w-4 shrink-0 mt-0.5" />
57
+ <InfoBannerContent>
58
+ Health checks are reusable templates — they can be assigned to
59
+ additional systems at any time.
60
+ </InfoBannerContent>
61
+ </InfoBanner>
62
+
63
+ <div className="space-y-2">
64
+ <div className="relative">
65
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
66
+ <Input
67
+ placeholder="Search systems..."
68
+ value={query}
69
+ onChange={(e) => setQuery(e.target.value)}
70
+ className="pl-9"
71
+ disabled={loading}
72
+ />
73
+ </div>
74
+
75
+ {loading ? (
76
+ <p className="text-sm text-muted-foreground italic px-1">
77
+ Loading systems...
78
+ </p>
79
+ ) : filteredSystems.length === 0 ? (
80
+ <p className="text-sm text-muted-foreground italic px-1">
81
+ {systems.length === 0
82
+ ? "No systems exist yet. Create one from the catalog first."
83
+ : "No systems match your search."}
84
+ </p>
85
+ ) : (
86
+ <div className="max-h-[420px] overflow-y-auto rounded-md border border-border/50 divide-y divide-border/50">
87
+ {filteredSystems.map((system) => {
88
+ const isChecked = selectedSet.has(system.id);
89
+ return (
90
+ <button
91
+ key={system.id}
92
+ type="button"
93
+ onClick={() => toggle({ systemId: system.id })}
94
+ className="flex items-start gap-3 w-full px-3 py-2.5 text-left hover:bg-muted/40 transition-colors"
95
+ >
96
+ <Checkbox
97
+ checked={isChecked}
98
+ onCheckedChange={() => toggle({ systemId: system.id })}
99
+ className="mt-0.5"
100
+ />
101
+ <div className="min-w-0 flex-1">
102
+ <div className="text-sm font-medium truncate">
103
+ {system.name}
104
+ </div>
105
+ {system.description && (
106
+ <div className="text-xs text-muted-foreground truncate">
107
+ {system.description}
108
+ </div>
109
+ )}
110
+ </div>
111
+ </button>
112
+ );
113
+ })}
114
+ </div>
115
+ )}
116
+
117
+ {selectedSystemIds.length > 0 && (
118
+ <p className="text-xs text-muted-foreground px-1">
119
+ {selectedSystemIds.length} system
120
+ {selectedSystemIds.length === 1 ? "" : "s"} selected
121
+ </p>
122
+ )}
123
+ </div>
124
+ </div>
125
+ );
126
+ };