@checkstack/catalog-frontend 0.10.6 → 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.
Files changed (40) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/package.json +16 -15
  3. package/src/api.ts +6 -1
  4. package/src/components/CatalogConfigPage.tsx +337 -271
  5. package/src/components/CatalogPage.tsx +172 -11
  6. package/src/components/EnvironmentEditor.tsx +220 -0
  7. package/src/components/EnvironmentPreviewPicker.tsx +61 -0
  8. package/src/components/SystemDetailPage.tsx +47 -34
  9. package/src/components/SystemEditor.tsx +6 -0
  10. package/src/components/SystemEnvironmentsEditor.tsx +98 -0
  11. package/src/components/browse/CatalogBrowseHealth.tsx +36 -0
  12. package/src/components/browse/CatalogBrowseToolbar.tsx +173 -0
  13. package/src/components/browse/CatalogGroupSection.tsx +165 -0
  14. package/src/components/browse/CatalogSystemRow.tsx +63 -0
  15. package/src/components/browse/browseState.logic.test.ts +125 -0
  16. package/src/components/browse/browseState.logic.ts +158 -0
  17. package/src/components/browse/filterEntities.logic.test.ts +479 -0
  18. package/src/components/browse/filterEntities.logic.ts +360 -0
  19. package/src/components/browse/healthRollup.logic.test.ts +126 -0
  20. package/src/components/browse/healthRollup.logic.ts +120 -0
  21. package/src/components/browse/healthStatuses.logic.test.ts +39 -0
  22. package/src/components/browse/healthStatuses.logic.ts +29 -0
  23. package/src/components/environment-fields.logic.test.ts +111 -0
  24. package/src/components/environment-fields.logic.ts +98 -0
  25. package/src/components/environment-preview.logic.test.ts +76 -0
  26. package/src/components/environment-preview.logic.ts +61 -0
  27. package/src/components/manage/AssignMenu.tsx +78 -0
  28. package/src/components/manage/EnvironmentsTab.tsx +230 -0
  29. package/src/components/manage/GroupsTab.tsx +274 -0
  30. package/src/components/manage/SystemsTab.tsx +430 -0
  31. package/src/hooks/useCatalogBrowseState.ts +107 -0
  32. package/src/hooks/useDebouncedValue.ts +21 -0
  33. package/src/index.tsx +32 -20
  34. package/src/utils/formatDate.logic.test.ts +44 -0
  35. package/src/utils/formatDate.logic.ts +27 -0
  36. package/src/utils/normalizeMetadata.logic.test.ts +67 -0
  37. package/src/utils/normalizeMetadata.logic.ts +53 -0
  38. package/src/components/DraggableSystem.tsx +0 -200
  39. package/src/components/DroppableGroup.tsx +0 -174
  40. package/src/components/UserMenuItems.tsx +0 -31
@@ -1,45 +1,36 @@
1
- import { useState, useEffect } from "react";
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
- catalogAccess,
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
- Card,
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 { Plus, FolderPlus, LayoutGrid, Server } from "lucide-react";
17
+ import { Server, LayoutGrid, Boxes } from "lucide-react";
38
18
  import { SystemEditor } from "./SystemEditor";
39
19
  import { GroupEditor } from "./GroupEditor";
40
- import { DraggableSystem, SystemDragOverlay } from "./DraggableSystem";
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
- const systems = systemsData?.systems ?? [];
92
- const groups = groupsData ?? [];
93
- const loading = systemsLoading || groupsLoading;
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({ ...confirmModal, isOpen: false });
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({ ...confirmModal, isOpen: false });
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: (_data, variables) => {
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
- // DnD event handlers
277
- const handleDragStart = ({ active }: DragStartEvent) => {
278
- setActiveSystemId(String(active.id));
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 handleDragOver = ({ over }: DragOverEvent) => {
282
- setOverGroupId(over ? String(over.id) : undefined);
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 handleDragEnd = ({ active, over }: DragEndEvent) => {
286
- setActiveSystemId(undefined);
287
- setOverGroupId(undefined);
288
-
289
- if (!over) return;
290
-
291
- const systemId = String(active.id);
292
- const groupId = String(over.id);
293
- const targetGroup = groups.find((g) => g.id === groupId);
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 handleDragCancel = () => {
302
- setActiveSystemId(undefined);
303
- setOverGroupId(undefined);
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 activeSystem = activeSystemId
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 and logical groups within your infrastructure"
431
+ subtitle="Manage systems, logical groups, and environments"
324
432
  icon={Server}
325
433
  loading={loading || accessLoading}
326
434
  allowed={canManage}
327
435
  >
328
- <DndContext
329
- sensors={sensors}
330
- onDragStart={handleDragStart}
331
- onDragOver={handleDragOver}
332
- onDragEnd={handleDragEnd}
333
- onDragCancel={handleDragCancel}
334
- >
335
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
336
- {/* Systems Management */}
337
- <Card>
338
- <CardHeader>
339
- <CardHeaderRow>
340
- <CardTitle className="flex items-center gap-2">
341
- <Server className="w-5 h-5 text-muted-foreground" />
342
- Systems
343
- </CardTitle>
344
- <Tip
345
- plugin={catalogPluginMetadata}
346
- id="systems.create"
347
- title="Start here: add a system"
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
- {/* Drag overlay the floating ghost shown while dragging */}
480
- {/* dropAnimation must be null (not undefined) per @dnd-kit API to disable the fly-back animation */}
481
- { }
482
- <DragOverlay dropAnimation={null}>
483
- {activeSystem ? <SystemDragOverlay system={activeSystem} /> : undefined}
484
- </DragOverlay>
485
- </DndContext>
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 })}