@checkstack/catalog-frontend 0.10.7 → 0.11.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 +133 -0
- package/package.json +10 -9
- package/src/api.ts +6 -1
- package/src/components/CatalogConfigPage.tsx +337 -271
- package/src/components/CatalogPage.tsx +172 -11
- package/src/components/EnvironmentEditor.tsx +220 -0
- package/src/components/EnvironmentPreviewPicker.tsx +61 -0
- package/src/components/SystemDetailPage.tsx +47 -34
- package/src/components/SystemEditor.tsx +6 -0
- package/src/components/SystemEnvironmentsEditor.tsx +98 -0
- package/src/components/browse/CatalogBrowseHealth.tsx +36 -0
- package/src/components/browse/CatalogBrowseToolbar.tsx +173 -0
- package/src/components/browse/CatalogGroupSection.tsx +165 -0
- package/src/components/browse/CatalogSystemRow.tsx +63 -0
- package/src/components/browse/browseState.logic.test.ts +125 -0
- package/src/components/browse/browseState.logic.ts +158 -0
- package/src/components/browse/filterEntities.logic.test.ts +479 -0
- package/src/components/browse/filterEntities.logic.ts +360 -0
- package/src/components/browse/healthRollup.logic.test.ts +126 -0
- package/src/components/browse/healthRollup.logic.ts +120 -0
- package/src/components/browse/healthStatuses.logic.test.ts +39 -0
- package/src/components/browse/healthStatuses.logic.ts +29 -0
- package/src/components/environment-fields.logic.test.ts +111 -0
- package/src/components/environment-fields.logic.ts +98 -0
- package/src/components/environment-preview.logic.test.ts +76 -0
- package/src/components/environment-preview.logic.ts +61 -0
- package/src/components/manage/AssignMenu.tsx +78 -0
- package/src/components/manage/EnvironmentsTab.tsx +230 -0
- package/src/components/manage/GroupsTab.tsx +274 -0
- package/src/components/manage/SystemsTab.tsx +430 -0
- package/src/hooks/useCatalogBrowseState.ts +107 -0
- package/src/hooks/useDebouncedValue.ts +21 -0
- package/src/index.tsx +32 -20
- package/src/utils/formatDate.logic.test.ts +44 -0
- package/src/utils/formatDate.logic.ts +27 -0
- package/src/utils/normalizeMetadata.logic.test.ts +67 -0
- package/src/utils/normalizeMetadata.logic.ts +53 -0
- package/src/components/DraggableSystem.tsx +0 -200
- package/src/components/DroppableGroup.tsx +0 -174
- package/src/components/UserMenuItems.tsx +0 -31
|
@@ -1,45 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
2
|
import { useSearchParams } from "react-router-dom";
|
|
3
|
-
import {
|
|
4
|
-
DndContext,
|
|
5
|
-
DragOverlay,
|
|
6
|
-
PointerSensor,
|
|
7
|
-
TouchSensor,
|
|
8
|
-
useSensor,
|
|
9
|
-
useSensors,
|
|
10
|
-
type DragStartEvent,
|
|
11
|
-
type DragEndEvent,
|
|
12
|
-
type DragOverEvent,
|
|
13
|
-
} from "@dnd-kit/core";
|
|
14
3
|
import {
|
|
15
4
|
useApi,
|
|
16
5
|
accessApiRef,
|
|
17
6
|
usePluginClient,
|
|
18
7
|
} from "@checkstack/frontend-api";
|
|
19
|
-
import { System, CatalogApi } from "../api";
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
pluginMetadata as catalogPluginMetadata,
|
|
23
|
-
} from "@checkstack/catalog-common";
|
|
24
|
-
import { Tip } from "@checkstack/tips-frontend";
|
|
8
|
+
import { System, Environment, CatalogApi } from "../api";
|
|
9
|
+
import { catalogAccess } from "@checkstack/catalog-common";
|
|
10
|
+
import type { CatalogHealthStatuses } from "@checkstack/catalog-common";
|
|
25
11
|
import {
|
|
26
12
|
PageLayout,
|
|
27
|
-
|
|
28
|
-
CardHeader,
|
|
29
|
-
CardHeaderRow,
|
|
30
|
-
CardTitle,
|
|
31
|
-
CardContent,
|
|
32
|
-
Button,
|
|
33
|
-
EmptyState,
|
|
13
|
+
Tabs,
|
|
34
14
|
ConfirmationModal,
|
|
35
15
|
useToast,
|
|
36
16
|
} from "@checkstack/ui";
|
|
37
|
-
import {
|
|
17
|
+
import { Server, LayoutGrid, Boxes } from "lucide-react";
|
|
38
18
|
import { SystemEditor } from "./SystemEditor";
|
|
39
19
|
import { GroupEditor } from "./GroupEditor";
|
|
40
|
-
import {
|
|
41
|
-
import { DroppableGroup } from "./DroppableGroup";
|
|
20
|
+
import { EnvironmentEditor } from "./EnvironmentEditor";
|
|
42
21
|
import { extractErrorMessage } from "@checkstack/common";
|
|
22
|
+
import { useCatalogBrowseState } from "../hooks/useCatalogBrowseState";
|
|
23
|
+
import { CatalogBrowseToolbar } from "./browse/CatalogBrowseToolbar";
|
|
24
|
+
import { CatalogBrowseHealth } from "./browse/CatalogBrowseHealth";
|
|
25
|
+
import {
|
|
26
|
+
collectTagOptions,
|
|
27
|
+
filterManagementLists,
|
|
28
|
+
} from "./browse/filterEntities.logic";
|
|
29
|
+
import { SystemsTab } from "./manage/SystemsTab";
|
|
30
|
+
import { GroupsTab } from "./manage/GroupsTab";
|
|
31
|
+
import { EnvironmentsTab } from "./manage/EnvironmentsTab";
|
|
32
|
+
|
|
33
|
+
type ManageTab = "systems" | "groups" | "environments";
|
|
43
34
|
|
|
44
35
|
export const CatalogConfigPage = () => {
|
|
45
36
|
const catalogClient = usePluginClient(CatalogApi);
|
|
@@ -49,11 +40,22 @@ export const CatalogConfigPage = () => {
|
|
|
49
40
|
const { allowed: canManage, loading: accessLoading } = accessApi.useAccess(
|
|
50
41
|
catalogAccess.system.manage,
|
|
51
42
|
);
|
|
43
|
+
// Environment CRUD is gated on its own access rule, independent of the
|
|
44
|
+
// system-level manage permission that gates the rest of this page.
|
|
45
|
+
const { allowed: canManageEnvironments } = accessApi.useAccess(
|
|
46
|
+
catalogAccess.environment.manage,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const [activeTab, setActiveTab] = useState<ManageTab>("systems");
|
|
52
50
|
|
|
53
51
|
// Dialog state
|
|
54
52
|
const [isSystemEditorOpen, setIsSystemEditorOpen] = useState(false);
|
|
55
53
|
const [editingSystem, setEditingSystem] = useState<System | undefined>();
|
|
56
54
|
const [isGroupEditorOpen, setIsGroupEditorOpen] = useState(false);
|
|
55
|
+
const [isEnvironmentEditorOpen, setIsEnvironmentEditorOpen] = useState(false);
|
|
56
|
+
const [editingEnvironment, setEditingEnvironment] = useState<
|
|
57
|
+
Environment | undefined
|
|
58
|
+
>();
|
|
57
59
|
|
|
58
60
|
// Confirmation modal state
|
|
59
61
|
const [confirmModal, setConfirmModal] = useState<{
|
|
@@ -68,12 +70,6 @@ export const CatalogConfigPage = () => {
|
|
|
68
70
|
onConfirm: () => {},
|
|
69
71
|
});
|
|
70
72
|
|
|
71
|
-
// Drag-and-drop state
|
|
72
|
-
const [activeSystemId, setActiveSystemId] = useState<string | undefined>();
|
|
73
|
-
const [overGroupId, setOverGroupId] = useState<string | undefined>();
|
|
74
|
-
// Tracks which system was most recently added to which group (for glow animation)
|
|
75
|
-
const [recentlyAdded, setRecentlyAdded] = useState<{ systemId: string; groupId: string } | undefined>();
|
|
76
|
-
|
|
77
73
|
// Fetch systems with useQuery
|
|
78
74
|
const {
|
|
79
75
|
data: systemsData,
|
|
@@ -88,13 +84,89 @@ export const CatalogConfigPage = () => {
|
|
|
88
84
|
refetch: refetchGroups,
|
|
89
85
|
} = catalogClient.getGroups.useQuery({});
|
|
90
86
|
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
87
|
+
// Fetch environments with useQuery
|
|
88
|
+
const {
|
|
89
|
+
data: environmentsData,
|
|
90
|
+
isLoading: environmentsLoading,
|
|
91
|
+
refetch: refetchEnvironments,
|
|
92
|
+
} = catalogClient.listEnvironments.useQuery({});
|
|
93
|
+
|
|
94
|
+
const systems = useMemo(() => systemsData?.systems ?? [], [systemsData]);
|
|
95
|
+
const groups = useMemo(() => groupsData ?? [], [groupsData]);
|
|
96
|
+
const environments = useMemo(
|
|
97
|
+
() => environmentsData ?? [],
|
|
98
|
+
[environmentsData],
|
|
99
|
+
);
|
|
100
|
+
const loading = systemsLoading || groupsLoading || environmentsLoading;
|
|
101
|
+
|
|
102
|
+
// Shared browse/manage filter state (search + group/health/tag), reusing the
|
|
103
|
+
// URL-state hook + pure filter logic so the management lists get the same
|
|
104
|
+
// search/filter/grouping as the browse view.
|
|
105
|
+
const browse = useCatalogBrowseState();
|
|
106
|
+
const tagOptions = useMemo(() => collectTagOptions(systems), [systems]);
|
|
107
|
+
|
|
108
|
+
// Bulk health reported by the optional CatalogBrowseHealthSlot filler (shared
|
|
109
|
+
// with the browse view). `null` until/unless a filler reports; powers the
|
|
110
|
+
// health filter and enables the toolbar's health control.
|
|
111
|
+
const [healthStatuses, setHealthStatuses] =
|
|
112
|
+
useState<CatalogHealthStatuses | null>(null);
|
|
113
|
+
const healthEnabled = healthStatuses !== null;
|
|
114
|
+
const systemIds = useMemo(() => systems.map((s) => s.id), [systems]);
|
|
115
|
+
|
|
116
|
+
const filtered = useMemo(
|
|
117
|
+
() =>
|
|
118
|
+
filterManagementLists({
|
|
119
|
+
systems,
|
|
120
|
+
groups,
|
|
121
|
+
// Filter on the debounced query so typing stays smooth on large lists.
|
|
122
|
+
state: { ...browse.state, query: browse.debouncedQuery },
|
|
123
|
+
statuses: healthStatuses ?? undefined,
|
|
124
|
+
}),
|
|
125
|
+
[systems, groups, browse.state, browse.debouncedQuery, healthStatuses],
|
|
126
|
+
);
|
|
127
|
+
const visibleSystems = filtered.systems;
|
|
128
|
+
const visibleGroups = filtered.groups;
|
|
129
|
+
|
|
130
|
+
// Environments aren't part of filterManagementLists; filter by name on the
|
|
131
|
+
// shared query so the search box works on the Environments tab too.
|
|
132
|
+
const visibleEnvironments = useMemo(() => {
|
|
133
|
+
const q = browse.debouncedQuery.trim().toLowerCase();
|
|
134
|
+
if (!q) return environments;
|
|
135
|
+
return environments.filter((e) => e.name.toLowerCase().includes(q));
|
|
136
|
+
}, [environments, browse.debouncedQuery]);
|
|
137
|
+
|
|
138
|
+
// systemId -> the group ids it belongs to (built from the full group set so a
|
|
139
|
+
// filtered-out group still shows membership).
|
|
140
|
+
const systemGroupMap = useMemo(() => {
|
|
141
|
+
const map = new Map<string, string[]>();
|
|
142
|
+
for (const group of groups) {
|
|
143
|
+
for (const sysId of group.systemIds ?? []) {
|
|
144
|
+
const existing = map.get(sysId) ?? [];
|
|
145
|
+
existing.push(group.id);
|
|
146
|
+
map.set(sysId, existing);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return map;
|
|
150
|
+
}, [groups]);
|
|
151
|
+
|
|
152
|
+
// systemId -> the environment ids it's attached to (environments carry
|
|
153
|
+
// `systemIds`, mirroring groups).
|
|
154
|
+
const systemEnvMap = useMemo(() => {
|
|
155
|
+
const map = new Map<string, string[]>();
|
|
156
|
+
for (const env of environments) {
|
|
157
|
+
for (const sysId of env.systemIds ?? []) {
|
|
158
|
+
const existing = map.get(sysId) ?? [];
|
|
159
|
+
existing.push(env.id);
|
|
160
|
+
map.set(sysId, existing);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return map;
|
|
164
|
+
}, [environments]);
|
|
94
165
|
|
|
95
166
|
// Handle ?action=create URL parameter (from command palette)
|
|
96
167
|
useEffect(() => {
|
|
97
168
|
if (searchParams.get("action") === "create" && canManage) {
|
|
169
|
+
setActiveTab("systems");
|
|
98
170
|
setIsSystemEditorOpen(true);
|
|
99
171
|
// Clear the URL param after opening
|
|
100
172
|
searchParams.delete("action");
|
|
@@ -102,18 +174,6 @@ export const CatalogConfigPage = () => {
|
|
|
102
174
|
}
|
|
103
175
|
}, [searchParams, canManage, setSearchParams]);
|
|
104
176
|
|
|
105
|
-
// DnD sensors — pointer for desktop, touch for mobile
|
|
106
|
-
const sensors = useSensors(
|
|
107
|
-
useSensor(PointerSensor, {
|
|
108
|
-
// 8px movement tolerance prevents accidental drags on clicks
|
|
109
|
-
activationConstraint: { distance: 8 },
|
|
110
|
-
}),
|
|
111
|
-
useSensor(TouchSensor, {
|
|
112
|
-
// 250ms delay for touch to avoid conflicts with scrolling
|
|
113
|
-
activationConstraint: { delay: 250, tolerance: 8 },
|
|
114
|
-
}),
|
|
115
|
-
);
|
|
116
|
-
|
|
117
177
|
// Mutations
|
|
118
178
|
const createSystemMutation = catalogClient.createSystem.useMutation({
|
|
119
179
|
onSuccess: () => {
|
|
@@ -122,9 +182,7 @@ export const CatalogConfigPage = () => {
|
|
|
122
182
|
void refetchSystems();
|
|
123
183
|
},
|
|
124
184
|
onError: (error) => {
|
|
125
|
-
toast.error(
|
|
126
|
-
extractErrorMessage(error, "Failed to create system"),
|
|
127
|
-
);
|
|
185
|
+
toast.error(extractErrorMessage(error, "Failed to create system"));
|
|
128
186
|
},
|
|
129
187
|
});
|
|
130
188
|
|
|
@@ -136,22 +194,18 @@ export const CatalogConfigPage = () => {
|
|
|
136
194
|
void refetchSystems();
|
|
137
195
|
},
|
|
138
196
|
onError: (error) => {
|
|
139
|
-
toast.error(
|
|
140
|
-
extractErrorMessage(error, "Failed to update system"),
|
|
141
|
-
);
|
|
197
|
+
toast.error(extractErrorMessage(error, "Failed to update system"));
|
|
142
198
|
},
|
|
143
199
|
});
|
|
144
200
|
|
|
145
201
|
const deleteSystemMutation = catalogClient.deleteSystem.useMutation({
|
|
146
202
|
onSuccess: () => {
|
|
147
203
|
toast.success("System deleted successfully");
|
|
148
|
-
setConfirmModal({ ...
|
|
204
|
+
setConfirmModal((prev) => ({ ...prev, isOpen: false }));
|
|
149
205
|
void refetchSystems();
|
|
150
206
|
},
|
|
151
207
|
onError: (error) => {
|
|
152
|
-
toast.error(
|
|
153
|
-
extractErrorMessage(error, "Failed to delete system"),
|
|
154
|
-
);
|
|
208
|
+
toast.error(extractErrorMessage(error, "Failed to delete system"));
|
|
155
209
|
},
|
|
156
210
|
});
|
|
157
211
|
|
|
@@ -162,22 +216,18 @@ export const CatalogConfigPage = () => {
|
|
|
162
216
|
void refetchGroups();
|
|
163
217
|
},
|
|
164
218
|
onError: (error) => {
|
|
165
|
-
toast.error(
|
|
166
|
-
extractErrorMessage(error, "Failed to create group"),
|
|
167
|
-
);
|
|
219
|
+
toast.error(extractErrorMessage(error, "Failed to create group"));
|
|
168
220
|
},
|
|
169
221
|
});
|
|
170
222
|
|
|
171
223
|
const deleteGroupMutation = catalogClient.deleteGroup.useMutation({
|
|
172
224
|
onSuccess: () => {
|
|
173
225
|
toast.success("Group deleted successfully");
|
|
174
|
-
setConfirmModal({ ...
|
|
226
|
+
setConfirmModal((prev) => ({ ...prev, isOpen: false }));
|
|
175
227
|
void refetchGroups();
|
|
176
228
|
},
|
|
177
229
|
onError: (error) => {
|
|
178
|
-
toast.error(
|
|
179
|
-
extractErrorMessage(error, "Failed to delete group"),
|
|
180
|
-
);
|
|
230
|
+
toast.error(extractErrorMessage(error, "Failed to delete group"));
|
|
181
231
|
},
|
|
182
232
|
});
|
|
183
233
|
|
|
@@ -187,24 +237,18 @@ export const CatalogConfigPage = () => {
|
|
|
187
237
|
void refetchGroups();
|
|
188
238
|
},
|
|
189
239
|
onError: (error) => {
|
|
190
|
-
toast.error(
|
|
191
|
-
extractErrorMessage(error, "Failed to update group name"),
|
|
192
|
-
);
|
|
240
|
+
toast.error(extractErrorMessage(error, "Failed to update group name"));
|
|
193
241
|
throw error;
|
|
194
242
|
},
|
|
195
243
|
});
|
|
196
244
|
|
|
197
245
|
const addSystemToGroupMutation = catalogClient.addSystemToGroup.useMutation({
|
|
198
|
-
onSuccess: (
|
|
246
|
+
onSuccess: () => {
|
|
199
247
|
toast.success("System added to group successfully");
|
|
200
|
-
setRecentlyAdded({ systemId: variables.systemId, groupId: variables.groupId });
|
|
201
|
-
setTimeout(() => setRecentlyAdded(undefined), 1500);
|
|
202
248
|
void refetchGroups();
|
|
203
249
|
},
|
|
204
250
|
onError: (error) => {
|
|
205
|
-
toast.error(
|
|
206
|
-
extractErrorMessage(error, "Failed to add system to group"),
|
|
207
|
-
);
|
|
251
|
+
toast.error(extractErrorMessage(error, "Failed to add system to group"));
|
|
208
252
|
},
|
|
209
253
|
});
|
|
210
254
|
|
|
@@ -221,6 +265,52 @@ export const CatalogConfigPage = () => {
|
|
|
221
265
|
},
|
|
222
266
|
});
|
|
223
267
|
|
|
268
|
+
const createEnvironmentMutation = catalogClient.createEnvironment.useMutation({
|
|
269
|
+
onSuccess: () => {
|
|
270
|
+
toast.success("Environment created successfully");
|
|
271
|
+
setIsEnvironmentEditorOpen(false);
|
|
272
|
+
setEditingEnvironment(undefined);
|
|
273
|
+
},
|
|
274
|
+
onError: (error) => {
|
|
275
|
+
toast.error(extractErrorMessage(error, "Failed to create environment"));
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const updateEnvironmentMutation = catalogClient.updateEnvironment.useMutation({
|
|
280
|
+
onSuccess: () => {
|
|
281
|
+
toast.success("Environment updated successfully");
|
|
282
|
+
setIsEnvironmentEditorOpen(false);
|
|
283
|
+
setEditingEnvironment(undefined);
|
|
284
|
+
},
|
|
285
|
+
onError: (error) => {
|
|
286
|
+
toast.error(extractErrorMessage(error, "Failed to update environment"));
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const deleteEnvironmentMutation = catalogClient.deleteEnvironment.useMutation({
|
|
291
|
+
onSuccess: () => {
|
|
292
|
+
toast.success("Environment deleted successfully");
|
|
293
|
+
setConfirmModal((prev) => ({ ...prev, isOpen: false }));
|
|
294
|
+
},
|
|
295
|
+
onError: (error) => {
|
|
296
|
+
toast.error(extractErrorMessage(error, "Failed to delete environment"));
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// System<->environment membership is a full-set replace; the add/remove
|
|
301
|
+
// helpers below recompute the set from `systemEnvMap`.
|
|
302
|
+
const setSystemEnvironmentsMutation =
|
|
303
|
+
catalogClient.setSystemEnvironments.useMutation({
|
|
304
|
+
onSuccess: () => {
|
|
305
|
+
void refetchEnvironments();
|
|
306
|
+
},
|
|
307
|
+
onError: (error) => {
|
|
308
|
+
toast.error(
|
|
309
|
+
extractErrorMessage(error, "Failed to update system environments"),
|
|
310
|
+
);
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
224
314
|
// Handlers
|
|
225
315
|
const handleSaveSystem = async (data: {
|
|
226
316
|
name: string;
|
|
@@ -243,8 +333,22 @@ export const CatalogConfigPage = () => {
|
|
|
243
333
|
isOpen: true,
|
|
244
334
|
title: "Delete System",
|
|
245
335
|
message: `Are you sure you want to delete "${system?.name}"? This will remove the system from all groups as well.`,
|
|
336
|
+
onConfirm: () => deleteSystemMutation.mutate(id),
|
|
337
|
+
});
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const handleBulkDeleteSystems = (ids: string[]) => {
|
|
341
|
+
if (ids.length === 0) return;
|
|
342
|
+
if (ids.length === 1) {
|
|
343
|
+
handleDeleteSystem(ids[0]);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
setConfirmModal({
|
|
347
|
+
isOpen: true,
|
|
348
|
+
title: `Delete ${ids.length} systems`,
|
|
349
|
+
message: `Are you sure you want to delete these ${ids.length} systems? This will remove them from all groups as well.`,
|
|
246
350
|
onConfirm: () => {
|
|
247
|
-
deleteSystemMutation.mutate(id);
|
|
351
|
+
for (const id of ids) deleteSystemMutation.mutate(id);
|
|
248
352
|
},
|
|
249
353
|
});
|
|
250
354
|
};
|
|
@@ -255,9 +359,7 @@ export const CatalogConfigPage = () => {
|
|
|
255
359
|
isOpen: true,
|
|
256
360
|
title: "Delete Group",
|
|
257
361
|
message: `Are you sure you want to delete "${group?.name}"? This action cannot be undone.`,
|
|
258
|
-
onConfirm: () =>
|
|
259
|
-
deleteGroupMutation.mutate(id);
|
|
260
|
-
},
|
|
362
|
+
onConfirm: () => deleteGroupMutation.mutate(id),
|
|
261
363
|
});
|
|
262
364
|
};
|
|
263
365
|
|
|
@@ -273,216 +375,162 @@ export const CatalogConfigPage = () => {
|
|
|
273
375
|
updateGroupMutation.mutate({ id, data: { name: newName } });
|
|
274
376
|
};
|
|
275
377
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
378
|
+
const handleSaveEnvironment = async (data: {
|
|
379
|
+
name: string;
|
|
380
|
+
description?: string;
|
|
381
|
+
metadata?: Record<string, string>;
|
|
382
|
+
}) => {
|
|
383
|
+
if (editingEnvironment) {
|
|
384
|
+
updateEnvironmentMutation.mutate({
|
|
385
|
+
environmentId: editingEnvironment.id,
|
|
386
|
+
data,
|
|
387
|
+
});
|
|
388
|
+
} else {
|
|
389
|
+
createEnvironmentMutation.mutate(data);
|
|
390
|
+
}
|
|
279
391
|
};
|
|
280
392
|
|
|
281
|
-
const
|
|
282
|
-
|
|
393
|
+
const handleDeleteEnvironment = (id: string) => {
|
|
394
|
+
const environment = environments.find((e) => e.id === id);
|
|
395
|
+
setConfirmModal({
|
|
396
|
+
isOpen: true,
|
|
397
|
+
title: "Delete Environment",
|
|
398
|
+
message: `Are you sure you want to delete "${environment?.name}"? This will remove it from all systems.`,
|
|
399
|
+
onConfirm: () => deleteEnvironmentMutation.mutate({ environmentId: id }),
|
|
400
|
+
});
|
|
283
401
|
};
|
|
284
402
|
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
// Block if already assigned to this group
|
|
296
|
-
if (targetGroup?.systemIds?.includes(systemId)) return;
|
|
297
|
-
|
|
298
|
-
handleAddSystemToGroup(systemId, groupId);
|
|
403
|
+
const handleAddSystemEnvironment = (
|
|
404
|
+
systemId: string,
|
|
405
|
+
environmentId: string,
|
|
406
|
+
) => {
|
|
407
|
+
const current = systemEnvMap.get(systemId) ?? [];
|
|
408
|
+
if (current.includes(environmentId)) return;
|
|
409
|
+
setSystemEnvironmentsMutation.mutate({
|
|
410
|
+
systemId,
|
|
411
|
+
environmentIds: [...current, environmentId],
|
|
412
|
+
});
|
|
299
413
|
};
|
|
300
414
|
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
415
|
+
const handleRemoveSystemEnvironment = (
|
|
416
|
+
systemId: string,
|
|
417
|
+
environmentId: string,
|
|
418
|
+
) => {
|
|
419
|
+
const current = systemEnvMap.get(systemId) ?? [];
|
|
420
|
+
setSystemEnvironmentsMutation.mutate({
|
|
421
|
+
systemId,
|
|
422
|
+
environmentIds: current.filter((id) => id !== environmentId),
|
|
423
|
+
});
|
|
304
424
|
};
|
|
305
425
|
|
|
306
|
-
const
|
|
307
|
-
? systems.find((s) => s.id === activeSystemId)
|
|
308
|
-
: undefined;
|
|
309
|
-
|
|
310
|
-
// Build a map of systemId → groupIds for quick lookup
|
|
311
|
-
const systemGroupMap = new Map<string, string[]>();
|
|
312
|
-
for (const group of groups) {
|
|
313
|
-
for (const sysId of group.systemIds ?? []) {
|
|
314
|
-
const existing = systemGroupMap.get(sysId) ?? [];
|
|
315
|
-
existing.push(group.id);
|
|
316
|
-
systemGroupMap.set(sysId, existing);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
426
|
+
const hasContent = systems.length > 0 || groups.length > 0;
|
|
319
427
|
|
|
320
428
|
return (
|
|
321
429
|
<PageLayout
|
|
322
430
|
title="Catalog Management"
|
|
323
|
-
subtitle="Manage systems
|
|
431
|
+
subtitle="Manage systems, logical groups, and environments"
|
|
324
432
|
icon={Server}
|
|
325
433
|
loading={loading || accessLoading}
|
|
326
434
|
allowed={canManage}
|
|
327
435
|
>
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
description="A system is anything you want Checkstack to keep an eye on — a service, a host, a job, a database. Almost everything else (health checks, SLOs, incidents, notifications) hangs off systems."
|
|
349
|
-
side="bottom"
|
|
350
|
-
align="end"
|
|
351
|
-
>
|
|
352
|
-
<Button size="sm" onClick={() => setIsSystemEditorOpen(true)}>
|
|
353
|
-
<Plus className="w-4 h-4 mr-2" />
|
|
354
|
-
Add System
|
|
355
|
-
</Button>
|
|
356
|
-
</Tip>
|
|
357
|
-
</CardHeaderRow>
|
|
358
|
-
{systems.length > 0 && groups.length > 0 && (
|
|
359
|
-
<p className="text-xs text-muted-foreground mt-1">
|
|
360
|
-
Drag a system onto a group, or use the{" "}
|
|
361
|
-
<FolderPlus className="w-3 h-3 inline" /> button to assign it.
|
|
362
|
-
</p>
|
|
363
|
-
)}
|
|
364
|
-
</CardHeader>
|
|
365
|
-
<CardContent className="space-y-4">
|
|
366
|
-
{systems.length === 0 ? (
|
|
367
|
-
<EmptyState
|
|
368
|
-
icon={<Server className="size-10" />}
|
|
369
|
-
title="No systems yet"
|
|
370
|
-
description="Systems are the things you monitor. Once you add one, you can attach health checks, SLOs, maintenance windows and incident history to it."
|
|
371
|
-
steps={[
|
|
372
|
-
"Click “Add System” to register your first service, host or job.",
|
|
373
|
-
"Group related systems so dashboards and on-call rotations stay tidy.",
|
|
374
|
-
"Wire health checks to a system so its health status reflects reality and subscribers get notified on changes.",
|
|
375
|
-
]}
|
|
376
|
-
actions={
|
|
377
|
-
<Button onClick={() => setIsSystemEditorOpen(true)}>
|
|
378
|
-
<Plus className="w-4 h-4 mr-2" />
|
|
379
|
-
Add your first system
|
|
380
|
-
</Button>
|
|
381
|
-
}
|
|
382
|
-
/>
|
|
383
|
-
) : (
|
|
384
|
-
<div className="space-y-2">
|
|
385
|
-
{systems.map((system) => (
|
|
386
|
-
<DraggableSystem
|
|
387
|
-
key={system.id}
|
|
388
|
-
system={system}
|
|
389
|
-
groups={groups}
|
|
390
|
-
assignedGroupIds={systemGroupMap.get(system.id) ?? []}
|
|
391
|
-
onEdit={(s) => {
|
|
392
|
-
setEditingSystem(s);
|
|
393
|
-
setIsSystemEditorOpen(true);
|
|
394
|
-
}}
|
|
395
|
-
onDelete={handleDeleteSystem}
|
|
396
|
-
onAddToGroup={handleAddSystemToGroup}
|
|
397
|
-
/>
|
|
398
|
-
))}
|
|
399
|
-
</div>
|
|
400
|
-
)}
|
|
401
|
-
</CardContent>
|
|
402
|
-
</Card>
|
|
403
|
-
|
|
404
|
-
{/* Groups Management */}
|
|
405
|
-
<Card>
|
|
406
|
-
<CardHeader>
|
|
407
|
-
<CardHeaderRow>
|
|
408
|
-
<CardTitle className="flex items-center gap-2">
|
|
409
|
-
<LayoutGrid className="w-5 h-5 text-muted-foreground" />
|
|
410
|
-
Groups
|
|
411
|
-
</CardTitle>
|
|
412
|
-
<Tip
|
|
413
|
-
plugin={catalogPluginMetadata}
|
|
414
|
-
id="groups.create"
|
|
415
|
-
title="Group systems that belong together"
|
|
416
|
-
description="Groups are how Checkstack rolls up status: a group is healthy when all of its systems are healthy. Use them per team, per product area, or per environment."
|
|
417
|
-
side="bottom"
|
|
418
|
-
align="end"
|
|
419
|
-
>
|
|
420
|
-
<Button size="sm" onClick={() => setIsGroupEditorOpen(true)}>
|
|
421
|
-
<Plus className="w-4 h-4 mr-2" />
|
|
422
|
-
Add Group
|
|
423
|
-
</Button>
|
|
424
|
-
</Tip>
|
|
425
|
-
</CardHeaderRow>
|
|
426
|
-
{groups.length > 0 && systems.length > 0 && (
|
|
427
|
-
<p className="text-xs text-muted-foreground mt-1">
|
|
428
|
-
Drop systems here to assign them to a group.
|
|
429
|
-
</p>
|
|
430
|
-
)}
|
|
431
|
-
</CardHeader>
|
|
432
|
-
<CardContent className="space-y-4">
|
|
433
|
-
{groups.length === 0 ? (
|
|
434
|
-
<EmptyState
|
|
435
|
-
icon={<LayoutGrid className="size-10" />}
|
|
436
|
-
title="No groups yet"
|
|
437
|
-
description="Groups roll up the health of multiple systems into a single status — useful for teams, products or environments."
|
|
438
|
-
steps={[
|
|
439
|
-
"Click “Add Group” and give it a meaningful name.",
|
|
440
|
-
"Drag systems from the left panel into the group, or use the assign button on each system.",
|
|
441
|
-
"Subscribe to the group from the status page to alert your team on any rolled-up incident.",
|
|
442
|
-
]}
|
|
443
|
-
actions={
|
|
444
|
-
<Button onClick={() => setIsGroupEditorOpen(true)}>
|
|
445
|
-
<Plus className="w-4 h-4 mr-2" />
|
|
446
|
-
Add your first group
|
|
447
|
-
</Button>
|
|
448
|
-
}
|
|
449
|
-
/>
|
|
450
|
-
) : (
|
|
451
|
-
<div className="space-y-2">
|
|
452
|
-
{groups.map((group) => (
|
|
453
|
-
<DroppableGroup
|
|
454
|
-
key={group.id}
|
|
455
|
-
group={group}
|
|
456
|
-
systems={systems}
|
|
457
|
-
isOver={overGroupId === group.id}
|
|
458
|
-
isDragging={activeSystemId !== undefined}
|
|
459
|
-
draggingSystemAlreadyInGroup={
|
|
460
|
-
activeSystemId !== undefined &&
|
|
461
|
-
(group.systemIds ?? []).includes(activeSystemId)
|
|
462
|
-
}
|
|
463
|
-
newlyAddedSystemId={
|
|
464
|
-
recentlyAdded?.groupId === group.id
|
|
465
|
-
? recentlyAdded.systemId
|
|
466
|
-
: undefined
|
|
467
|
-
}
|
|
468
|
-
onDeleteGroup={handleDeleteGroup}
|
|
469
|
-
onUpdateGroupName={handleUpdateGroupName}
|
|
470
|
-
onRemoveSystem={handleRemoveSystemFromGroup}
|
|
471
|
-
/>
|
|
472
|
-
))}
|
|
473
|
-
</div>
|
|
474
|
-
)}
|
|
475
|
-
</CardContent>
|
|
476
|
-
</Card>
|
|
436
|
+
{hasContent && (
|
|
437
|
+
<div className="mb-4">
|
|
438
|
+
{/* Headless health boundary (slot unfilled → renders nothing). */}
|
|
439
|
+
<CatalogBrowseHealth
|
|
440
|
+
systemIds={systemIds}
|
|
441
|
+
onStatuses={setHealthStatuses}
|
|
442
|
+
/>
|
|
443
|
+
<CatalogBrowseToolbar
|
|
444
|
+
query={browse.state.query}
|
|
445
|
+
onQueryChange={browse.setQuery}
|
|
446
|
+
group={browse.state.group}
|
|
447
|
+
onGroupChange={browse.setGroup}
|
|
448
|
+
groups={groups}
|
|
449
|
+
health={browse.state.health}
|
|
450
|
+
onHealthChange={browse.setHealth}
|
|
451
|
+
healthEnabled={healthEnabled}
|
|
452
|
+
tag={browse.state.tag}
|
|
453
|
+
onTagChange={browse.setTag}
|
|
454
|
+
tagOptions={tagOptions}
|
|
455
|
+
/>
|
|
477
456
|
</div>
|
|
457
|
+
)}
|
|
458
|
+
|
|
459
|
+
<Tabs
|
|
460
|
+
className="mb-4"
|
|
461
|
+
activeTab={activeTab}
|
|
462
|
+
onTabChange={(id) => setActiveTab(id as ManageTab)}
|
|
463
|
+
items={[
|
|
464
|
+
{ id: "systems", label: "Systems", icon: <Server className="h-4 w-4" /> },
|
|
465
|
+
{ id: "groups", label: "Groups", icon: <LayoutGrid className="h-4 w-4" /> },
|
|
466
|
+
{
|
|
467
|
+
id: "environments",
|
|
468
|
+
label: "Environments",
|
|
469
|
+
icon: <Boxes className="h-4 w-4" />,
|
|
470
|
+
},
|
|
471
|
+
]}
|
|
472
|
+
/>
|
|
478
473
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
474
|
+
{activeTab === "systems" && (
|
|
475
|
+
<SystemsTab
|
|
476
|
+
systems={visibleSystems}
|
|
477
|
+
totalCount={systems.length}
|
|
478
|
+
allGroups={groups}
|
|
479
|
+
systemGroupMap={systemGroupMap}
|
|
480
|
+
allEnvironments={environments}
|
|
481
|
+
systemEnvMap={systemEnvMap}
|
|
482
|
+
onAddToEnvironment={handleAddSystemEnvironment}
|
|
483
|
+
onRemoveFromEnvironment={handleRemoveSystemEnvironment}
|
|
484
|
+
onAddSystem={() => {
|
|
485
|
+
setEditingSystem(undefined);
|
|
486
|
+
setIsSystemEditorOpen(true);
|
|
487
|
+
}}
|
|
488
|
+
onEditSystem={(s) => {
|
|
489
|
+
setEditingSystem(s);
|
|
490
|
+
setIsSystemEditorOpen(true);
|
|
491
|
+
}}
|
|
492
|
+
onDeleteSystem={handleDeleteSystem}
|
|
493
|
+
onBulkDeleteSystems={handleBulkDeleteSystems}
|
|
494
|
+
onAddToGroup={handleAddSystemToGroup}
|
|
495
|
+
onRemoveFromGroup={handleRemoveSystemFromGroup}
|
|
496
|
+
onClearFilters={browse.clearFilters}
|
|
497
|
+
/>
|
|
498
|
+
)}
|
|
499
|
+
|
|
500
|
+
{activeTab === "groups" && (
|
|
501
|
+
<GroupsTab
|
|
502
|
+
groups={visibleGroups}
|
|
503
|
+
totalCount={groups.length}
|
|
504
|
+
allSystems={systems}
|
|
505
|
+
onAddGroup={() => setIsGroupEditorOpen(true)}
|
|
506
|
+
onDeleteGroup={handleDeleteGroup}
|
|
507
|
+
onRenameGroup={handleUpdateGroupName}
|
|
508
|
+
onAddToGroup={handleAddSystemToGroup}
|
|
509
|
+
onRemoveFromGroup={handleRemoveSystemFromGroup}
|
|
510
|
+
onClearFilters={browse.clearFilters}
|
|
511
|
+
/>
|
|
512
|
+
)}
|
|
513
|
+
|
|
514
|
+
{activeTab === "environments" && (
|
|
515
|
+
<EnvironmentsTab
|
|
516
|
+
environments={visibleEnvironments}
|
|
517
|
+
totalCount={environments.length}
|
|
518
|
+
canManage={canManageEnvironments}
|
|
519
|
+
allSystems={systems}
|
|
520
|
+
onAddSystemToEnvironment={handleAddSystemEnvironment}
|
|
521
|
+
onRemoveSystemFromEnvironment={handleRemoveSystemEnvironment}
|
|
522
|
+
onAddEnvironment={() => {
|
|
523
|
+
setEditingEnvironment(undefined);
|
|
524
|
+
setIsEnvironmentEditorOpen(true);
|
|
525
|
+
}}
|
|
526
|
+
onEditEnvironment={(env) => {
|
|
527
|
+
setEditingEnvironment(env);
|
|
528
|
+
setIsEnvironmentEditorOpen(true);
|
|
529
|
+
}}
|
|
530
|
+
onDeleteEnvironment={handleDeleteEnvironment}
|
|
531
|
+
onClearFilters={browse.clearFilters}
|
|
532
|
+
/>
|
|
533
|
+
)}
|
|
486
534
|
|
|
487
535
|
{/* Dialogs */}
|
|
488
536
|
<SystemEditor
|
|
@@ -509,6 +557,24 @@ export const CatalogConfigPage = () => {
|
|
|
509
557
|
onSave={handleCreateGroup}
|
|
510
558
|
/>
|
|
511
559
|
|
|
560
|
+
<EnvironmentEditor
|
|
561
|
+
open={isEnvironmentEditorOpen}
|
|
562
|
+
onClose={() => {
|
|
563
|
+
setIsEnvironmentEditorOpen(false);
|
|
564
|
+
setEditingEnvironment(undefined);
|
|
565
|
+
}}
|
|
566
|
+
onSave={handleSaveEnvironment}
|
|
567
|
+
initialData={
|
|
568
|
+
editingEnvironment
|
|
569
|
+
? {
|
|
570
|
+
name: editingEnvironment.name,
|
|
571
|
+
description: editingEnvironment.description ?? undefined,
|
|
572
|
+
metadata: editingEnvironment.metadata,
|
|
573
|
+
}
|
|
574
|
+
: undefined
|
|
575
|
+
}
|
|
576
|
+
/>
|
|
577
|
+
|
|
512
578
|
<ConfirmationModal
|
|
513
579
|
isOpen={confirmModal.isOpen}
|
|
514
580
|
onClose={() => setConfirmModal({ ...confirmModal, isOpen: false })}
|