@checkstack/dashboard-frontend 0.1.0 → 0.2.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,117 @@
1
1
  # @checkstack/dashboard-frontend
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 180be38: # Queue Lag Warning
8
+
9
+ Added a queue lag warning system that displays alerts when pending jobs exceed configurable thresholds.
10
+
11
+ ## Features
12
+
13
+ - **Backend Stats API**: New `getStats`, `getLagStatus`, and `updateLagThresholds` RPC endpoints
14
+ - **Signal-based Updates**: `QUEUE_LAG_CHANGED` signal for real-time frontend updates
15
+ - **Aggregated Stats**: `QueueManager.getAggregatedStats()` sums stats across all queues
16
+ - **Configurable Thresholds**: Warning (default 100) and Critical (default 500) thresholds stored in config
17
+ - **Dashboard Integration**: Queue lag alert displayed on main Dashboard (access-gated)
18
+ - **Queue Settings Page**: Lag alert and Performance Tuning guidance card with concurrency tips
19
+
20
+ ## UI Changes
21
+
22
+ - Queue lag alert banner appears on Dashboard and Queue Settings when pending jobs exceed thresholds
23
+ - New "Performance Tuning" card with concurrency settings guidance and bottleneck indicators
24
+
25
+ - 7a23261: ## TanStack Query Integration
26
+
27
+ Migrated all frontend components to use `usePluginClient` hook with TanStack Query integration, replacing the legacy `forPlugin()` pattern.
28
+
29
+ ### New Features
30
+
31
+ - **`usePluginClient` hook**: Provides type-safe access to plugin APIs with `.useQuery()` and `.useMutation()` methods
32
+ - **Automatic request deduplication**: Multiple components requesting the same data share a single network request
33
+ - **Built-in caching**: Configurable stale time and cache duration per query
34
+ - **Loading/error states**: TanStack Query provides `isLoading`, `error`, `isRefetching` states automatically
35
+ - **Background refetching**: Stale data is automatically refreshed when components mount
36
+
37
+ ### Contract Changes
38
+
39
+ All RPC contracts now require `operationType: "query"` or `operationType: "mutation"` metadata:
40
+
41
+ ```typescript
42
+ const getItems = proc()
43
+ .meta({ operationType: "query", access: [access.read] })
44
+ .output(z.array(itemSchema))
45
+ .query();
46
+
47
+ const createItem = proc()
48
+ .meta({ operationType: "mutation", access: [access.manage] })
49
+ .input(createItemSchema)
50
+ .output(itemSchema)
51
+ .mutation();
52
+ ```
53
+
54
+ ### Migration
55
+
56
+ ```typescript
57
+ // Before (forPlugin pattern)
58
+ const api = useApi(myPluginApiRef);
59
+ const [items, setItems] = useState<Item[]>([]);
60
+ useEffect(() => {
61
+ api.getItems().then(setItems);
62
+ }, [api]);
63
+
64
+ // After (usePluginClient pattern)
65
+ const client = usePluginClient(MyPluginApi);
66
+ const { data: items, isLoading } = client.getItems.useQuery({});
67
+ ```
68
+
69
+ ### Bug Fixes
70
+
71
+ - Fixed `rpc.test.ts` test setup for middleware type inference
72
+ - Fixed `SearchDialog` to use `setQuery` instead of deprecated `search` method
73
+ - Fixed null→undefined warnings in notification and queue frontends
74
+
75
+ ### Patch Changes
76
+
77
+ - Updated dependencies [180be38]
78
+ - Updated dependencies [7a23261]
79
+ - @checkstack/queue-frontend@0.2.0
80
+ - @checkstack/frontend-api@0.2.0
81
+ - @checkstack/common@0.3.0
82
+ - @checkstack/auth-frontend@0.3.0
83
+ - @checkstack/catalog-frontend@0.3.0
84
+ - @checkstack/catalog-common@1.2.0
85
+ - @checkstack/command-frontend@0.2.0
86
+ - @checkstack/command-common@0.2.0
87
+ - @checkstack/healthcheck-common@0.4.0
88
+ - @checkstack/incident-common@0.3.0
89
+ - @checkstack/maintenance-common@0.3.0
90
+ - @checkstack/notification-common@0.2.0
91
+ - @checkstack/ui@0.2.1
92
+ - @checkstack/signal-frontend@0.0.7
93
+
94
+ ## 0.1.1
95
+
96
+ ### Patch Changes
97
+
98
+ - Updated dependencies [9faec1f]
99
+ - Updated dependencies [95eeec7]
100
+ - Updated dependencies [f533141]
101
+ - @checkstack/auth-frontend@0.2.0
102
+ - @checkstack/catalog-common@1.1.0
103
+ - @checkstack/catalog-frontend@0.2.0
104
+ - @checkstack/command-common@0.1.0
105
+ - @checkstack/command-frontend@0.1.0
106
+ - @checkstack/common@0.2.0
107
+ - @checkstack/frontend-api@0.1.0
108
+ - @checkstack/healthcheck-common@0.3.0
109
+ - @checkstack/incident-common@0.2.0
110
+ - @checkstack/maintenance-common@0.2.0
111
+ - @checkstack/notification-common@0.1.0
112
+ - @checkstack/ui@0.2.0
113
+ - @checkstack/signal-frontend@0.0.6
114
+
3
115
  ## 0.1.0
4
116
 
5
117
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/dashboard-frontend",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "src/index.tsx",
6
6
  "scripts": {
@@ -9,22 +9,24 @@
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
+ "@tanstack/react-query": "^5.90.18",
27
+ "lucide-react": "^0.344.0",
25
28
  "react": "^18.2.0",
26
- "react-router-dom": "^6.22.0",
27
- "lucide-react": "^0.344.0"
29
+ "react-router-dom": "^6.22.0"
28
30
  },
29
31
  "devDependencies": {
30
32
  "typescript": "^5.0.0",
package/src/Dashboard.tsx CHANGED
@@ -1,8 +1,12 @@
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
+ ExtensionSlot,
7
+ } from "@checkstack/frontend-api";
8
+ import {
9
+ CatalogApi,
6
10
  catalogRoutes,
7
11
  SystemStateBadgesSlot,
8
12
  System,
@@ -17,6 +21,7 @@ import { IncidentApi } from "@checkstack/incident-common";
17
21
  import { MaintenanceApi } from "@checkstack/maintenance-common";
18
22
  import { HEALTH_CHECK_RUN_COMPLETED } from "@checkstack/healthcheck-common";
19
23
  import { useSignal } from "@checkstack/signal-frontend";
24
+ import { useQueryClient } from "@tanstack/react-query";
20
25
  import {
21
26
  Card,
22
27
  CardHeader,
@@ -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,189 @@
1
+ import React, { createContext, useContext, useCallback, useMemo } from "react";
2
+ import { usePluginClient } from "@checkstack/frontend-api";
3
+ import { useSignal } from "@checkstack/signal-frontend";
4
+ import { useQueryClient } from "@tanstack/react-query";
5
+ import {
6
+ HealthCheckApi,
7
+ HEALTH_CHECK_RUN_COMPLETED,
8
+ type SystemHealthStatusResponse,
9
+ } from "@checkstack/healthcheck-common";
10
+ import {
11
+ IncidentApi,
12
+ INCIDENT_UPDATED,
13
+ type IncidentWithSystems,
14
+ } from "@checkstack/incident-common";
15
+ import {
16
+ MaintenanceApi,
17
+ MAINTENANCE_UPDATED,
18
+ type MaintenanceWithSystems,
19
+ } from "@checkstack/maintenance-common";
20
+
21
+ /**
22
+ * Data structure for system badge data.
23
+ */
24
+ export interface SystemBadgeData {
25
+ health?: SystemHealthStatusResponse;
26
+ incidents: IncidentWithSystems[];
27
+ maintenances: MaintenanceWithSystems[];
28
+ }
29
+
30
+ /**
31
+ * Context value provided by SystemBadgeDataProvider.
32
+ */
33
+ interface SystemBadgeDataContextValue {
34
+ getSystemBadgeData: (systemId: string) => SystemBadgeData | undefined;
35
+ loading: boolean;
36
+ }
37
+
38
+ const SystemBadgeDataContext = createContext<
39
+ SystemBadgeDataContextValue | undefined
40
+ >(undefined);
41
+
42
+ interface SystemBadgeDataProviderProps {
43
+ systemIds: string[];
44
+ children: React.ReactNode;
45
+ }
46
+
47
+ /**
48
+ * Provider that bulk-fetches badge data (health, incidents, maintenances)
49
+ * for multiple systems using TanStack Query and provides it via context.
50
+ */
51
+ export const SystemBadgeDataProvider: React.FC<
52
+ SystemBadgeDataProviderProps
53
+ > = ({ systemIds, children }) => {
54
+ const queryClient = useQueryClient();
55
+ const healthCheckClient = usePluginClient(HealthCheckApi);
56
+ const incidentClient = usePluginClient(IncidentApi);
57
+ const maintenanceClient = usePluginClient(MaintenanceApi);
58
+
59
+ // -------------------------------------------------------------------------
60
+ // BULK QUERIES
61
+ // -------------------------------------------------------------------------
62
+
63
+ // Fetch bulk health status
64
+ const { data: healthData, isLoading: healthLoading } =
65
+ healthCheckClient.getBulkSystemHealthStatus.useQuery(
66
+ { systemIds },
67
+ { enabled: systemIds.length > 0, staleTime: 30_000 }
68
+ );
69
+
70
+ // Fetch bulk incidents
71
+ const { data: incidentData, isLoading: incidentLoading } =
72
+ incidentClient.getBulkIncidentsForSystems.useQuery(
73
+ { systemIds },
74
+ { enabled: systemIds.length > 0, staleTime: 30_000 }
75
+ );
76
+
77
+ // Fetch bulk maintenances
78
+ const { data: maintenanceData, isLoading: maintenanceLoading } =
79
+ maintenanceClient.getBulkMaintenancesForSystems.useQuery(
80
+ { systemIds },
81
+ { enabled: systemIds.length > 0, staleTime: 30_000 }
82
+ );
83
+
84
+ const loading = healthLoading || incidentLoading || maintenanceLoading;
85
+
86
+ // -------------------------------------------------------------------------
87
+ // SIGNAL HANDLERS - Invalidate queries on updates
88
+ // -------------------------------------------------------------------------
89
+
90
+ const refetchHealth = useCallback(
91
+ (systemId: string) => {
92
+ if (systemIds.includes(systemId)) {
93
+ // Invalidate the bulk query to refetch
94
+ queryClient.invalidateQueries({ queryKey: ["healthcheck"] });
95
+ }
96
+ },
97
+ [systemIds, queryClient]
98
+ );
99
+
100
+ useSignal(HEALTH_CHECK_RUN_COMPLETED, ({ systemId }) => {
101
+ refetchHealth(systemId);
102
+ });
103
+
104
+ const refetchIncidents = useCallback(
105
+ (affectedSystemIds: string[]) => {
106
+ const hasAffected = affectedSystemIds.some((id) =>
107
+ systemIds.includes(id)
108
+ );
109
+ if (hasAffected) {
110
+ queryClient.invalidateQueries({ queryKey: ["incident"] });
111
+ }
112
+ },
113
+ [systemIds, queryClient]
114
+ );
115
+
116
+ useSignal(INCIDENT_UPDATED, ({ systemIds: affectedIds }) => {
117
+ refetchIncidents(affectedIds);
118
+ });
119
+
120
+ const refetchMaintenances = useCallback(
121
+ (affectedSystemIds: string[]) => {
122
+ const hasAffected = affectedSystemIds.some((id) =>
123
+ systemIds.includes(id)
124
+ );
125
+ if (hasAffected) {
126
+ queryClient.invalidateQueries({ queryKey: ["maintenance"] });
127
+ }
128
+ },
129
+ [systemIds, queryClient]
130
+ );
131
+
132
+ useSignal(MAINTENANCE_UPDATED, ({ systemIds: affectedIds }) => {
133
+ refetchMaintenances(affectedIds);
134
+ });
135
+
136
+ // -------------------------------------------------------------------------
137
+ // CONTEXT VALUE
138
+ // -------------------------------------------------------------------------
139
+
140
+ const getSystemBadgeData = useCallback(
141
+ (systemId: string): SystemBadgeData | undefined => {
142
+ const health = healthData?.statuses[systemId];
143
+ const incidents = incidentData?.incidents[systemId];
144
+ const maintenances = maintenanceData?.maintenances[systemId];
145
+
146
+ // Return undefined if no data loaded yet
147
+ if (!health && !incidents && !maintenances) {
148
+ return undefined;
149
+ }
150
+
151
+ return {
152
+ health,
153
+ incidents: incidents || [],
154
+ maintenances: maintenances || [],
155
+ };
156
+ },
157
+ [healthData, incidentData, maintenanceData]
158
+ );
159
+
160
+ const contextValue = useMemo(
161
+ () => ({
162
+ getSystemBadgeData,
163
+ loading,
164
+ }),
165
+ [getSystemBadgeData, loading]
166
+ );
167
+
168
+ return (
169
+ <SystemBadgeDataContext.Provider value={contextValue}>
170
+ {children}
171
+ </SystemBadgeDataContext.Provider>
172
+ );
173
+ };
174
+
175
+ export function useSystemBadgeData(): SystemBadgeDataContextValue {
176
+ const context = useContext(SystemBadgeDataContext);
177
+ if (!context) {
178
+ throw new Error(
179
+ "useSystemBadgeData must be used within a SystemBadgeDataProvider"
180
+ );
181
+ }
182
+ return context;
183
+ }
184
+
185
+ export function useSystemBadgeDataOptional():
186
+ | SystemBadgeDataContextValue
187
+ | undefined {
188
+ return useContext(SystemBadgeDataContext);
189
+ }
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";