@checkstack/dashboard-frontend 0.1.1 → 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 +91 -0
- package/package.json +12 -10
- package/src/Dashboard.tsx +216 -176
- package/src/components/SystemBadgeDataProvider.tsx +189 -0
- package/src/index.tsx +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,96 @@
|
|
|
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
|
+
|
|
3
94
|
## 0.1.1
|
|
4
95
|
|
|
5
96
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/dashboard-frontend",
|
|
3
|
-
"version": "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/
|
|
22
|
+
"@checkstack/notification-common": "workspace:*",
|
|
23
|
+
"@checkstack/queue-frontend": "workspace:*",
|
|
22
24
|
"@checkstack/signal-frontend": "workspace:*",
|
|
23
25
|
"@checkstack/ui": "workspace:*",
|
|
24
|
-
"@
|
|
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, {
|
|
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
|
-
|
|
60
|
+
const getGroupId = (groupId: string) => `${CATALOG_PLUGIN_ID}:${groupId}`;
|
|
61
|
+
|
|
56
62
|
const statusToVariant = (
|
|
57
|
-
status:
|
|
58
|
-
):
|
|
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
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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(
|
|
213
|
+
return subscriptions.some(
|
|
214
|
+
(s: EnrichedSubscription) => s.groupId === fullId
|
|
215
|
+
);
|
|
169
216
|
};
|
|
170
217
|
|
|
171
|
-
const handleSubscribe =
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
197
|
-
);
|
|
228
|
+
);
|
|
229
|
+
};
|
|
198
230
|
|
|
199
|
-
const handleUnsubscribe =
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
<
|
|
242
|
-
<
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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";
|