@checkstack/dashboard-frontend 0.1.1 → 0.3.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,125 @@
1
1
  # @checkstack/dashboard-frontend
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4eed42d: Fix "No QueryClient set" error in containerized builds
8
+
9
+ **Problem**: The containerized application was throwing "No QueryClient set, use QueryClientProvider to set one" errors during plugin registration. This didn't happen in dev mode.
10
+
11
+ **Root Cause**: The `@tanstack/react-query` package was being bundled separately in different workspace packages, causing multiple React Query contexts. The `QueryClientProvider` from the main app wasn't visible to plugin code due to this module duplication.
12
+
13
+ **Changes**:
14
+
15
+ - `@checkstack/frontend-api`: Export `useQueryClient` from the centralized React Query import, ensuring all packages use the same context
16
+ - `@checkstack/dashboard-frontend`: Import `useQueryClient` from `@checkstack/frontend-api` instead of directly from `@tanstack/react-query`, and remove the direct dependency
17
+ - `@checkstack/frontend`: Add `@tanstack/react-query` to Vite's `resolve.dedupe` as a safety net
18
+
19
+ ### Patch Changes
20
+
21
+ - Updated dependencies [4eed42d]
22
+ - @checkstack/frontend-api@0.3.0
23
+ - @checkstack/auth-frontend@0.3.1
24
+ - @checkstack/catalog-common@1.2.1
25
+ - @checkstack/catalog-frontend@0.3.1
26
+ - @checkstack/command-frontend@0.2.1
27
+ - @checkstack/incident-common@0.3.1
28
+ - @checkstack/maintenance-common@0.3.1
29
+ - @checkstack/queue-frontend@0.2.1
30
+ - @checkstack/ui@0.2.2
31
+
32
+ ## 0.2.0
33
+
34
+ ### Minor Changes
35
+
36
+ - 180be38: # Queue Lag Warning
37
+
38
+ Added a queue lag warning system that displays alerts when pending jobs exceed configurable thresholds.
39
+
40
+ ## Features
41
+
42
+ - **Backend Stats API**: New `getStats`, `getLagStatus`, and `updateLagThresholds` RPC endpoints
43
+ - **Signal-based Updates**: `QUEUE_LAG_CHANGED` signal for real-time frontend updates
44
+ - **Aggregated Stats**: `QueueManager.getAggregatedStats()` sums stats across all queues
45
+ - **Configurable Thresholds**: Warning (default 100) and Critical (default 500) thresholds stored in config
46
+ - **Dashboard Integration**: Queue lag alert displayed on main Dashboard (access-gated)
47
+ - **Queue Settings Page**: Lag alert and Performance Tuning guidance card with concurrency tips
48
+
49
+ ## UI Changes
50
+
51
+ - Queue lag alert banner appears on Dashboard and Queue Settings when pending jobs exceed thresholds
52
+ - New "Performance Tuning" card with concurrency settings guidance and bottleneck indicators
53
+
54
+ - 7a23261: ## TanStack Query Integration
55
+
56
+ Migrated all frontend components to use `usePluginClient` hook with TanStack Query integration, replacing the legacy `forPlugin()` pattern.
57
+
58
+ ### New Features
59
+
60
+ - **`usePluginClient` hook**: Provides type-safe access to plugin APIs with `.useQuery()` and `.useMutation()` methods
61
+ - **Automatic request deduplication**: Multiple components requesting the same data share a single network request
62
+ - **Built-in caching**: Configurable stale time and cache duration per query
63
+ - **Loading/error states**: TanStack Query provides `isLoading`, `error`, `isRefetching` states automatically
64
+ - **Background refetching**: Stale data is automatically refreshed when components mount
65
+
66
+ ### Contract Changes
67
+
68
+ All RPC contracts now require `operationType: "query"` or `operationType: "mutation"` metadata:
69
+
70
+ ```typescript
71
+ const getItems = proc()
72
+ .meta({ operationType: "query", access: [access.read] })
73
+ .output(z.array(itemSchema))
74
+ .query();
75
+
76
+ const createItem = proc()
77
+ .meta({ operationType: "mutation", access: [access.manage] })
78
+ .input(createItemSchema)
79
+ .output(itemSchema)
80
+ .mutation();
81
+ ```
82
+
83
+ ### Migration
84
+
85
+ ```typescript
86
+ // Before (forPlugin pattern)
87
+ const api = useApi(myPluginApiRef);
88
+ const [items, setItems] = useState<Item[]>([]);
89
+ useEffect(() => {
90
+ api.getItems().then(setItems);
91
+ }, [api]);
92
+
93
+ // After (usePluginClient pattern)
94
+ const client = usePluginClient(MyPluginApi);
95
+ const { data: items, isLoading } = client.getItems.useQuery({});
96
+ ```
97
+
98
+ ### Bug Fixes
99
+
100
+ - Fixed `rpc.test.ts` test setup for middleware type inference
101
+ - Fixed `SearchDialog` to use `setQuery` instead of deprecated `search` method
102
+ - Fixed null→undefined warnings in notification and queue frontends
103
+
104
+ ### Patch Changes
105
+
106
+ - Updated dependencies [180be38]
107
+ - Updated dependencies [7a23261]
108
+ - @checkstack/queue-frontend@0.2.0
109
+ - @checkstack/frontend-api@0.2.0
110
+ - @checkstack/common@0.3.0
111
+ - @checkstack/auth-frontend@0.3.0
112
+ - @checkstack/catalog-frontend@0.3.0
113
+ - @checkstack/catalog-common@1.2.0
114
+ - @checkstack/command-frontend@0.2.0
115
+ - @checkstack/command-common@0.2.0
116
+ - @checkstack/healthcheck-common@0.4.0
117
+ - @checkstack/incident-common@0.3.0
118
+ - @checkstack/maintenance-common@0.3.0
119
+ - @checkstack/notification-common@0.2.0
120
+ - @checkstack/ui@0.2.1
121
+ - @checkstack/signal-frontend@0.0.7
122
+
3
123
  ## 0.1.1
4
124
 
5
125
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/dashboard-frontend",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "src/index.tsx",
6
6
  "scripts": {
@@ -9,22 +9,23 @@
9
9
  "lint:code": "eslint . --max-warnings 0"
10
10
  },
11
11
  "dependencies": {
12
- "@checkstack/frontend-api": "workspace:*",
13
12
  "@checkstack/auth-frontend": "workspace:*",
14
- "@checkstack/common": "workspace:*",
15
- "@checkstack/command-frontend": "workspace:*",
16
- "@checkstack/command-common": "workspace:*",
17
- "@checkstack/notification-common": "workspace:*",
18
13
  "@checkstack/catalog-common": "workspace:*",
14
+ "@checkstack/catalog-frontend": "workspace:*",
15
+ "@checkstack/command-common": "workspace:*",
16
+ "@checkstack/command-frontend": "workspace:*",
17
+ "@checkstack/common": "workspace:*",
18
+ "@checkstack/frontend-api": "workspace:*",
19
+ "@checkstack/healthcheck-common": "workspace:*",
19
20
  "@checkstack/incident-common": "workspace:*",
20
21
  "@checkstack/maintenance-common": "workspace:*",
21
- "@checkstack/healthcheck-common": "workspace:*",
22
+ "@checkstack/notification-common": "workspace:*",
23
+ "@checkstack/queue-frontend": "workspace:*",
22
24
  "@checkstack/signal-frontend": "workspace:*",
23
25
  "@checkstack/ui": "workspace:*",
24
- "@checkstack/catalog-frontend": "workspace:*",
26
+ "lucide-react": "^0.344.0",
25
27
  "react": "^18.2.0",
26
- "react-router-dom": "^6.22.0",
27
- "lucide-react": "^0.344.0"
28
+ "react-router-dom": "^6.22.0"
28
29
  },
29
30
  "devDependencies": {
30
31
  "typescript": "^5.0.0",
package/src/Dashboard.tsx CHANGED
@@ -1,8 +1,13 @@
1
- import React, { useEffect, useState, useCallback } from "react";
1
+ import React, { useState, useMemo } from "react";
2
2
  import { useNavigate } from "react-router-dom";
3
- import { useApi, rpcApiRef, ExtensionSlot } from "@checkstack/frontend-api";
4
- import { catalogApiRef } from "@checkstack/catalog-frontend";
5
3
  import {
4
+ useApi,
5
+ usePluginClient,
6
+ useQueryClient,
7
+ ExtensionSlot,
8
+ } from "@checkstack/frontend-api";
9
+ import {
10
+ CatalogApi,
6
11
  catalogRoutes,
7
12
  SystemStateBadgesSlot,
8
13
  System,
@@ -42,20 +47,21 @@ import {
42
47
  Terminal,
43
48
  } from "lucide-react";
44
49
  import { authApiRef } from "@checkstack/auth-frontend/api";
50
+ import { QueueLagAlert } from "@checkstack/queue-frontend";
51
+ import { SystemBadgeDataProvider } from "./components/SystemBadgeDataProvider";
45
52
 
46
53
  const CATALOG_PLUGIN_ID = "catalog";
47
54
  const MAX_TERMINAL_ENTRIES = 8;
48
55
 
49
- const getGroupId = (groupId: string) => `${CATALOG_PLUGIN_ID}.group.${groupId}`;
50
-
51
56
  interface GroupWithSystems extends Group {
52
57
  systems: System[];
53
58
  }
54
59
 
55
- // Map health check status to terminal entry variant
60
+ const getGroupId = (groupId: string) => `${CATALOG_PLUGIN_ID}:${groupId}`;
61
+
56
62
  const statusToVariant = (
57
- status: "healthy" | "degraded" | "unhealthy"
58
- ): TerminalEntry["variant"] => {
63
+ status: string
64
+ ): "default" | "success" | "warning" | "error" => {
59
65
  switch (status) {
60
66
  case "healthy": {
61
67
  return "success";
@@ -66,42 +72,117 @@ const statusToVariant = (
66
72
  case "unhealthy": {
67
73
  return "error";
68
74
  }
75
+ default: {
76
+ return "default";
77
+ }
69
78
  }
70
79
  };
71
80
 
72
81
  export const Dashboard: React.FC = () => {
73
- const catalogApi = useApi(catalogApiRef);
74
- const rpcApi = useApi(rpcApiRef);
75
- const notificationApi = rpcApi.forPlugin(NotificationApi);
76
- const incidentApi = rpcApi.forPlugin(IncidentApi);
77
- const maintenanceApi = rpcApi.forPlugin(MaintenanceApi);
82
+ const catalogClient = usePluginClient(CatalogApi);
83
+ const notificationClient = usePluginClient(NotificationApi);
84
+ const incidentClient = usePluginClient(IncidentApi);
85
+ const maintenanceClient = usePluginClient(MaintenanceApi);
86
+
78
87
  const navigate = useNavigate();
79
88
  const toast = useToast();
80
89
  const authApi = useApi(authApiRef);
90
+ const queryClient = useQueryClient();
81
91
  const { data: session } = authApi.useSession();
82
92
 
83
- const [groupsWithSystems, setGroupsWithSystems] = useState<
84
- GroupWithSystems[]
85
- >([]);
86
- const [loading, setLoading] = useState(true);
87
-
88
- // Overview statistics state
89
- const [systemsCount, setSystemsCount] = useState(0);
90
- const [activeIncidentsCount, setActiveIncidentsCount] = useState(0);
91
- const [activeMaintenancesCount, setActiveMaintenancesCount] = useState(0);
92
-
93
93
  // Terminal feed entries from real healthcheck signals
94
94
  const [terminalEntries, setTerminalEntries] = useState<TerminalEntry[]>([]);
95
95
 
96
- // Subscription state
97
- const [subscriptions, setSubscriptions] = useState<EnrichedSubscription[]>(
98
- []
99
- );
96
+ // Track per-group loading state for subscribe buttons
100
97
  const [subscriptionLoading, setSubscriptionLoading] = useState<
101
98
  Record<string, boolean>
102
99
  >({});
103
100
 
104
- // Listen for health check runs and add to terminal feed
101
+ // -------------------------------------------------------------------------
102
+ // DATA QUERIES
103
+ // -------------------------------------------------------------------------
104
+
105
+ // Fetch entities from catalog (groups and systems in one call)
106
+ const { data: entitiesData, isLoading: entitiesLoading } =
107
+ catalogClient.getEntities.useQuery({}, { staleTime: 30_000 });
108
+ const groups = entitiesData?.groups ?? [];
109
+ const systems = entitiesData?.systems ?? [];
110
+
111
+ // Fetch active incidents
112
+ const { data: incidentsData, isLoading: incidentsLoading } =
113
+ incidentClient.listIncidents.useQuery(
114
+ { includeResolved: false },
115
+ { staleTime: 30_000 }
116
+ );
117
+ const incidents = incidentsData?.incidents ?? [];
118
+
119
+ // Fetch active maintenances
120
+ const { data: maintenancesData, isLoading: maintenancesLoading } =
121
+ maintenanceClient.listMaintenances.useQuery(
122
+ { status: "in_progress" },
123
+ { staleTime: 30_000 }
124
+ );
125
+ const maintenances = maintenancesData?.maintenances ?? [];
126
+
127
+ // Fetch subscriptions (only when logged in)
128
+ const { data: subscriptions = [] } =
129
+ notificationClient.getSubscriptions.useQuery(
130
+ {},
131
+ { enabled: !!session, staleTime: 60_000 }
132
+ );
133
+
134
+ // Combined loading state
135
+ const loading = entitiesLoading || incidentsLoading || maintenancesLoading;
136
+
137
+ // -------------------------------------------------------------------------
138
+ // MUTATIONS
139
+ // -------------------------------------------------------------------------
140
+
141
+ const subscribeMutation = notificationClient.subscribe.useMutation({
142
+ onSuccess: () => {
143
+ toast.success("Subscribed to group notifications");
144
+ // Invalidate subscriptions query to refetch
145
+ queryClient.invalidateQueries({ queryKey: ["notification"] });
146
+ },
147
+ onError: (error: Error) => {
148
+ toast.error(error.message || "Failed to subscribe");
149
+ },
150
+ });
151
+
152
+ const unsubscribeMutation = notificationClient.unsubscribe.useMutation({
153
+ onSuccess: () => {
154
+ toast.success("Unsubscribed from group notifications");
155
+ queryClient.invalidateQueries({ queryKey: ["notification"] });
156
+ },
157
+ onError: (error: Error) => {
158
+ toast.error(error.message || "Failed to unsubscribe");
159
+ },
160
+ });
161
+
162
+ // -------------------------------------------------------------------------
163
+ // COMPUTED DATA
164
+ // -------------------------------------------------------------------------
165
+
166
+ // Derived statistics
167
+ const systemsCount = systems.length;
168
+ const activeIncidentsCount = incidents.length;
169
+ const activeMaintenancesCount = maintenances.length;
170
+
171
+ // Map groups to include their systems
172
+ const groupsWithSystems = useMemo<GroupWithSystems[]>(() => {
173
+ const systemMap = new Map(systems.map((s) => [s.id, s]));
174
+ return groups.map((group) => {
175
+ const groupSystems = (group.systemIds || [])
176
+ .map((id) => systemMap.get(id))
177
+ .filter((s): s is System => s !== undefined);
178
+ return { ...group, systems: groupSystems };
179
+ });
180
+ }, [groups, systems]);
181
+
182
+ // -------------------------------------------------------------------------
183
+ // SIGNAL HANDLERS
184
+ // -------------------------------------------------------------------------
185
+
105
186
  useSignal(
106
187
  HEALTH_CHECK_RUN_COMPLETED,
107
188
  ({ systemName, configurationName, status, latencyMs }) => {
@@ -119,45 +200,9 @@ export const Dashboard: React.FC = () => {
119
200
  }
120
201
  );
121
202
 
122
- useEffect(() => {
123
- if (session) {
124
- notificationApi.getSubscriptions().then(setSubscriptions);
125
- }
126
- }, [session, notificationApi]);
127
-
128
- useEffect(() => {
129
- Promise.all([
130
- catalogApi.getGroups(),
131
- catalogApi.getSystems(),
132
- incidentApi.listIncidents({ includeResolved: false }),
133
- maintenanceApi.listMaintenances({ status: "in_progress" }),
134
- ])
135
- .then(([groups, { systems }, { incidents }, { maintenances }]) => {
136
- // Set overview statistics
137
- setSystemsCount(systems.length);
138
- setActiveIncidentsCount(incidents.length);
139
- setActiveMaintenancesCount(maintenances.length);
140
-
141
- // Create a map of system IDs to systems
142
- const systemMap = new Map(systems.map((s) => [s.id, s]));
143
-
144
- // Map groups to include their systems
145
- const groupsData: GroupWithSystems[] = groups.map((group) => {
146
- const groupSystems = (group.systemIds || [])
147
- .map((id) => systemMap.get(id))
148
- .filter((s): s is System => s !== undefined);
149
-
150
- return {
151
- ...group,
152
- systems: groupSystems,
153
- };
154
- });
155
-
156
- setGroupsWithSystems(groupsData);
157
- })
158
- .catch(console.error)
159
- .finally(() => setLoading(false));
160
- }, [catalogApi, incidentApi, maintenanceApi]);
203
+ // -------------------------------------------------------------------------
204
+ // HANDLERS
205
+ // -------------------------------------------------------------------------
161
206
 
162
207
  const handleSystemClick = (systemId: string) => {
163
208
  navigate(resolveRoute(catalogRoutes.routes.systemDetail, { systemId }));
@@ -165,55 +210,40 @@ export const Dashboard: React.FC = () => {
165
210
 
166
211
  const isSubscribed = (groupId: string) => {
167
212
  const fullId = getGroupId(groupId);
168
- return subscriptions.some((s) => s.groupId === fullId);
213
+ return subscriptions.some(
214
+ (s: EnrichedSubscription) => s.groupId === fullId
215
+ );
169
216
  };
170
217
 
171
- const handleSubscribe = useCallback(
172
- async (groupId: string) => {
173
- const fullId = getGroupId(groupId);
174
- setSubscriptionLoading((prev) => ({ ...prev, [groupId]: true }));
175
- try {
176
- await notificationApi.subscribe({ groupId: fullId });
177
- setSubscriptions((prev) => [
178
- ...prev,
179
- {
180
- groupId: fullId,
181
- groupName: "",
182
- groupDescription: "",
183
- ownerPlugin: CATALOG_PLUGIN_ID,
184
- subscribedAt: new Date(),
185
- },
186
- ]);
187
- toast.success("Subscribed to group notifications");
188
- } catch (error) {
189
- const message =
190
- error instanceof Error ? error.message : "Failed to subscribe";
191
- toast.error(message);
192
- } finally {
193
- setSubscriptionLoading((prev) => ({ ...prev, [groupId]: false }));
218
+ const handleSubscribe = (groupId: string) => {
219
+ const fullId = getGroupId(groupId);
220
+ setSubscriptionLoading((prev) => ({ ...prev, [groupId]: true }));
221
+ subscribeMutation.mutate(
222
+ { groupId: fullId },
223
+ {
224
+ onSettled: () => {
225
+ setSubscriptionLoading((prev) => ({ ...prev, [groupId]: false }));
226
+ },
194
227
  }
195
- },
196
- [notificationApi, toast]
197
- );
228
+ );
229
+ };
198
230
 
199
- const handleUnsubscribe = useCallback(
200
- async (groupId: string) => {
201
- const fullId = getGroupId(groupId);
202
- setSubscriptionLoading((prev) => ({ ...prev, [groupId]: true }));
203
- try {
204
- await notificationApi.unsubscribe({ groupId: fullId });
205
- setSubscriptions((prev) => prev.filter((s) => s.groupId !== fullId));
206
- toast.success("Unsubscribed from group notifications");
207
- } catch (error) {
208
- const message =
209
- error instanceof Error ? error.message : "Failed to unsubscribe";
210
- toast.error(message);
211
- } finally {
212
- setSubscriptionLoading((prev) => ({ ...prev, [groupId]: false }));
231
+ const handleUnsubscribe = (groupId: string) => {
232
+ const fullId = getGroupId(groupId);
233
+ setSubscriptionLoading((prev) => ({ ...prev, [groupId]: true }));
234
+ unsubscribeMutation.mutate(
235
+ { groupId: fullId },
236
+ {
237
+ onSettled: () => {
238
+ setSubscriptionLoading((prev) => ({ ...prev, [groupId]: false }));
239
+ },
213
240
  }
214
- },
215
- [notificationApi, toast]
216
- );
241
+ );
242
+ };
243
+
244
+ // -------------------------------------------------------------------------
245
+ // RENDER
246
+ // -------------------------------------------------------------------------
217
247
 
218
248
  const renderGroupsContent = () => {
219
249
  if (loading) {
@@ -230,79 +260,89 @@ export const Dashboard: React.FC = () => {
230
260
  );
231
261
  }
232
262
 
263
+ // Collect all system IDs for bulk data fetching
264
+ const allSystemIds = groupsWithSystems.flatMap((g) =>
265
+ g.systems.map((s) => s.id)
266
+ );
267
+
233
268
  return (
234
- <div className="space-y-4">
235
- {groupsWithSystems.map((group) => (
236
- <Card
237
- key={group.id}
238
- className="border-border shadow-sm hover:shadow-md transition-shadow"
239
- >
240
- <CardHeader className="border-b border-border bg-muted/30">
241
- <div className="flex items-center gap-2">
242
- <LayoutGrid className="h-5 w-5 text-muted-foreground" />
243
- <CardTitle className="text-lg font-semibold text-foreground">
244
- {group.name}
245
- </CardTitle>
246
- <span className="ml-auto text-sm text-muted-foreground mr-2">
247
- {group.systems.length}{" "}
248
- {group.systems.length === 1 ? "system" : "systems"}
249
- </span>
250
- {session && (
251
- <SubscribeButton
252
- isSubscribed={isSubscribed(group.id)}
253
- onSubscribe={() => handleSubscribe(group.id)}
254
- onUnsubscribe={() => handleUnsubscribe(group.id)}
255
- loading={subscriptionLoading[group.id] || false}
256
- />
257
- )}
258
- </div>
259
- </CardHeader>
260
- <CardContent className="p-4">
261
- {group.systems.length === 0 ? (
262
- <div className="py-8 text-center">
263
- <p className="text-sm text-muted-foreground">
264
- No systems in this group yet
265
- </p>
269
+ <SystemBadgeDataProvider systemIds={allSystemIds}>
270
+ <div className="space-y-4">
271
+ {groupsWithSystems.map((group) => (
272
+ <Card
273
+ key={group.id}
274
+ className="border-border shadow-sm hover:shadow-md transition-shadow"
275
+ >
276
+ <CardHeader className="border-b border-border bg-muted/30">
277
+ <div className="flex items-center gap-2">
278
+ <LayoutGrid className="h-5 w-5 text-muted-foreground" />
279
+ <CardTitle className="text-lg font-semibold text-foreground">
280
+ {group.name}
281
+ </CardTitle>
282
+ <span className="ml-auto text-sm text-muted-foreground mr-2">
283
+ {group.systems.length}{" "}
284
+ {group.systems.length === 1 ? "system" : "systems"}
285
+ </span>
286
+ {session && (
287
+ <SubscribeButton
288
+ isSubscribed={isSubscribed(group.id)}
289
+ onSubscribe={() => handleSubscribe(group.id)}
290
+ onUnsubscribe={() => handleUnsubscribe(group.id)}
291
+ loading={subscriptionLoading[group.id] || false}
292
+ />
293
+ )}
266
294
  </div>
267
- ) : (
268
- <div
269
- className={`grid gap-3 ${
270
- group.systems.length === 1
271
- ? "grid-cols-1"
272
- : "grid-cols-1 sm:grid-cols-2"
273
- }`}
274
- >
275
- {group.systems.map((system) => (
276
- <button
277
- key={system.id}
278
- onClick={() => handleSystemClick(system.id)}
279
- className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card px-4 py-3 transition-all cursor-pointer hover:border-border/80 hover:shadow-sm text-left"
280
- >
281
- <div className="flex items-center gap-3 min-w-0 flex-1">
282
- <Activity className="h-4 w-4 text-muted-foreground flex-shrink-0" />
283
- <p className="text-sm font-medium text-foreground truncate">
284
- {system.name}
285
- </p>
286
- </div>
287
- <ExtensionSlot
288
- slot={SystemStateBadgesSlot}
289
- context={{ system }}
290
- />
291
- <ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
292
- </button>
293
- ))}
294
- </div>
295
- )}
296
- </CardContent>
297
- </Card>
298
- ))}
299
- </div>
295
+ </CardHeader>
296
+ <CardContent className="p-4">
297
+ {group.systems.length === 0 ? (
298
+ <div className="py-8 text-center">
299
+ <p className="text-sm text-muted-foreground">
300
+ No systems in this group yet
301
+ </p>
302
+ </div>
303
+ ) : (
304
+ <div
305
+ className={`grid gap-3 ${
306
+ group.systems.length === 1
307
+ ? "grid-cols-1"
308
+ : "grid-cols-1 sm:grid-cols-2"
309
+ }`}
310
+ >
311
+ {group.systems.map((system) => (
312
+ <button
313
+ key={system.id}
314
+ onClick={() => handleSystemClick(system.id)}
315
+ className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card px-4 py-3 transition-all cursor-pointer hover:border-border/80 hover:shadow-sm text-left"
316
+ >
317
+ <div className="flex items-center gap-3 min-w-0 flex-1">
318
+ <Activity className="h-4 w-4 text-muted-foreground flex-shrink-0" />
319
+ <p className="text-sm font-medium text-foreground truncate">
320
+ {system.name}
321
+ </p>
322
+ </div>
323
+ <ExtensionSlot
324
+ slot={SystemStateBadgesSlot}
325
+ context={{ system }}
326
+ />
327
+ <ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
328
+ </button>
329
+ ))}
330
+ </div>
331
+ )}
332
+ </CardContent>
333
+ </Card>
334
+ ))}
335
+ </div>
336
+ </SystemBadgeDataProvider>
300
337
  );
301
338
  };
302
339
 
303
340
  return (
304
341
  <>
305
342
  <div className="space-y-8 animate-in fade-in duration-500">
343
+ {/* Queue Lag Warning */}
344
+ <QueueLagAlert />
345
+
306
346
  {/* Overview Section */}
307
347
  <section>
308
348
  <SectionHeader
@@ -0,0 +1,188 @@
1
+ import React, { createContext, useContext, useCallback, useMemo } from "react";
2
+ import { usePluginClient, useQueryClient } from "@checkstack/frontend-api";
3
+ import { useSignal } from "@checkstack/signal-frontend";
4
+ import {
5
+ HealthCheckApi,
6
+ HEALTH_CHECK_RUN_COMPLETED,
7
+ type SystemHealthStatusResponse,
8
+ } from "@checkstack/healthcheck-common";
9
+ import {
10
+ IncidentApi,
11
+ INCIDENT_UPDATED,
12
+ type IncidentWithSystems,
13
+ } from "@checkstack/incident-common";
14
+ import {
15
+ MaintenanceApi,
16
+ MAINTENANCE_UPDATED,
17
+ type MaintenanceWithSystems,
18
+ } from "@checkstack/maintenance-common";
19
+
20
+ /**
21
+ * Data structure for system badge data.
22
+ */
23
+ export interface SystemBadgeData {
24
+ health?: SystemHealthStatusResponse;
25
+ incidents: IncidentWithSystems[];
26
+ maintenances: MaintenanceWithSystems[];
27
+ }
28
+
29
+ /**
30
+ * Context value provided by SystemBadgeDataProvider.
31
+ */
32
+ interface SystemBadgeDataContextValue {
33
+ getSystemBadgeData: (systemId: string) => SystemBadgeData | undefined;
34
+ loading: boolean;
35
+ }
36
+
37
+ const SystemBadgeDataContext = createContext<
38
+ SystemBadgeDataContextValue | undefined
39
+ >(undefined);
40
+
41
+ interface SystemBadgeDataProviderProps {
42
+ systemIds: string[];
43
+ children: React.ReactNode;
44
+ }
45
+
46
+ /**
47
+ * Provider that bulk-fetches badge data (health, incidents, maintenances)
48
+ * for multiple systems using TanStack Query and provides it via context.
49
+ */
50
+ export const SystemBadgeDataProvider: React.FC<
51
+ SystemBadgeDataProviderProps
52
+ > = ({ systemIds, children }) => {
53
+ const queryClient = useQueryClient();
54
+ const healthCheckClient = usePluginClient(HealthCheckApi);
55
+ const incidentClient = usePluginClient(IncidentApi);
56
+ const maintenanceClient = usePluginClient(MaintenanceApi);
57
+
58
+ // -------------------------------------------------------------------------
59
+ // BULK QUERIES
60
+ // -------------------------------------------------------------------------
61
+
62
+ // Fetch bulk health status
63
+ const { data: healthData, isLoading: healthLoading } =
64
+ healthCheckClient.getBulkSystemHealthStatus.useQuery(
65
+ { systemIds },
66
+ { enabled: systemIds.length > 0, staleTime: 30_000 }
67
+ );
68
+
69
+ // Fetch bulk incidents
70
+ const { data: incidentData, isLoading: incidentLoading } =
71
+ incidentClient.getBulkIncidentsForSystems.useQuery(
72
+ { systemIds },
73
+ { enabled: systemIds.length > 0, staleTime: 30_000 }
74
+ );
75
+
76
+ // Fetch bulk maintenances
77
+ const { data: maintenanceData, isLoading: maintenanceLoading } =
78
+ maintenanceClient.getBulkMaintenancesForSystems.useQuery(
79
+ { systemIds },
80
+ { enabled: systemIds.length > 0, staleTime: 30_000 }
81
+ );
82
+
83
+ const loading = healthLoading || incidentLoading || maintenanceLoading;
84
+
85
+ // -------------------------------------------------------------------------
86
+ // SIGNAL HANDLERS - Invalidate queries on updates
87
+ // -------------------------------------------------------------------------
88
+
89
+ const refetchHealth = useCallback(
90
+ (systemId: string) => {
91
+ if (systemIds.includes(systemId)) {
92
+ // Invalidate the bulk query to refetch
93
+ queryClient.invalidateQueries({ queryKey: ["healthcheck"] });
94
+ }
95
+ },
96
+ [systemIds, queryClient]
97
+ );
98
+
99
+ useSignal(HEALTH_CHECK_RUN_COMPLETED, ({ systemId }) => {
100
+ refetchHealth(systemId);
101
+ });
102
+
103
+ const refetchIncidents = useCallback(
104
+ (affectedSystemIds: string[]) => {
105
+ const hasAffected = affectedSystemIds.some((id) =>
106
+ systemIds.includes(id)
107
+ );
108
+ if (hasAffected) {
109
+ queryClient.invalidateQueries({ queryKey: ["incident"] });
110
+ }
111
+ },
112
+ [systemIds, queryClient]
113
+ );
114
+
115
+ useSignal(INCIDENT_UPDATED, ({ systemIds: affectedIds }) => {
116
+ refetchIncidents(affectedIds);
117
+ });
118
+
119
+ const refetchMaintenances = useCallback(
120
+ (affectedSystemIds: string[]) => {
121
+ const hasAffected = affectedSystemIds.some((id) =>
122
+ systemIds.includes(id)
123
+ );
124
+ if (hasAffected) {
125
+ queryClient.invalidateQueries({ queryKey: ["maintenance"] });
126
+ }
127
+ },
128
+ [systemIds, queryClient]
129
+ );
130
+
131
+ useSignal(MAINTENANCE_UPDATED, ({ systemIds: affectedIds }) => {
132
+ refetchMaintenances(affectedIds);
133
+ });
134
+
135
+ // -------------------------------------------------------------------------
136
+ // CONTEXT VALUE
137
+ // -------------------------------------------------------------------------
138
+
139
+ const getSystemBadgeData = useCallback(
140
+ (systemId: string): SystemBadgeData | undefined => {
141
+ const health = healthData?.statuses[systemId];
142
+ const incidents = incidentData?.incidents[systemId];
143
+ const maintenances = maintenanceData?.maintenances[systemId];
144
+
145
+ // Return undefined if no data loaded yet
146
+ if (!health && !incidents && !maintenances) {
147
+ return undefined;
148
+ }
149
+
150
+ return {
151
+ health,
152
+ incidents: incidents || [],
153
+ maintenances: maintenances || [],
154
+ };
155
+ },
156
+ [healthData, incidentData, maintenanceData]
157
+ );
158
+
159
+ const contextValue = useMemo(
160
+ () => ({
161
+ getSystemBadgeData,
162
+ loading,
163
+ }),
164
+ [getSystemBadgeData, loading]
165
+ );
166
+
167
+ return (
168
+ <SystemBadgeDataContext.Provider value={contextValue}>
169
+ {children}
170
+ </SystemBadgeDataContext.Provider>
171
+ );
172
+ };
173
+
174
+ export function useSystemBadgeData(): SystemBadgeDataContextValue {
175
+ const context = useContext(SystemBadgeDataContext);
176
+ if (!context) {
177
+ throw new Error(
178
+ "useSystemBadgeData must be used within a SystemBadgeDataProvider"
179
+ );
180
+ }
181
+ return context;
182
+ }
183
+
184
+ export function useSystemBadgeDataOptional():
185
+ | SystemBadgeDataContextValue
186
+ | undefined {
187
+ return useContext(SystemBadgeDataContext);
188
+ }
package/src/index.tsx CHANGED
@@ -18,3 +18,11 @@ export const dashboardPlugin: FrontendPlugin = {
18
18
  };
19
19
 
20
20
  export default dashboardPlugin;
21
+
22
+ // Export provider for use in other plugins
23
+ export {
24
+ SystemBadgeDataProvider,
25
+ useSystemBadgeData,
26
+ useSystemBadgeDataOptional,
27
+ type SystemBadgeData,
28
+ } from "./components/SystemBadgeDataProvider";