@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/package.json +10 -9
  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
@@ -0,0 +1,274 @@
1
+ import { useMemo, useState } from "react";
2
+ import {
3
+ Table,
4
+ TableBody,
5
+ TableCell,
6
+ TableHead,
7
+ TableHeader,
8
+ TableRow,
9
+ Button,
10
+ Input,
11
+ EmptyState,
12
+ ListEmptyState,
13
+ } from "@checkstack/ui";
14
+ import {
15
+ useProvenanceLock,
16
+ GitOpsSourceBadge,
17
+ } from "@checkstack/gitops-frontend";
18
+ import { Plus, LayoutGrid, Check, Pencil, Trash2, X } from "lucide-react";
19
+ import type { Group, System } from "../../api";
20
+ import { AssignMenu } from "./AssignMenu";
21
+
22
+ export interface GroupsTabProps {
23
+ /** Groups after search/filter. */
24
+ groups: Group[];
25
+ totalCount: number;
26
+ allSystems: System[];
27
+ onAddGroup: () => void;
28
+ onDeleteGroup: (id: string) => void;
29
+ onRenameGroup: (id: string, name: string) => void;
30
+ onAddToGroup: (systemId: string, groupId: string) => void;
31
+ onRemoveFromGroup: (groupId: string, systemId: string) => void;
32
+ onClearFilters: () => void;
33
+ }
34
+
35
+ export function GroupsTab(props: GroupsTabProps): React.ReactElement {
36
+ const { groups, totalCount, allSystems, onAddGroup } = props;
37
+
38
+ const systemsById = useMemo(() => {
39
+ const map = new Map<string, System>();
40
+ for (const system of allSystems) map.set(system.id, system);
41
+ return map;
42
+ }, [allSystems]);
43
+
44
+ const header = (
45
+ <div className="mb-4 flex items-center justify-between gap-2">
46
+ <h2 className="flex items-center gap-2 text-lg font-semibold">
47
+ <LayoutGrid className="h-5 w-5 text-muted-foreground" />
48
+ Groups
49
+ <span className="text-sm font-normal text-muted-foreground">
50
+ {totalCount}
51
+ </span>
52
+ </h2>
53
+ <Button size="sm" onClick={onAddGroup}>
54
+ <Plus className="mr-2 h-4 w-4" />
55
+ Add Group
56
+ </Button>
57
+ </div>
58
+ );
59
+
60
+ if (totalCount === 0) {
61
+ return (
62
+ <div>
63
+ {header}
64
+ <EmptyState
65
+ icon={<LayoutGrid className="size-10" />}
66
+ title="No groups yet"
67
+ description="Groups roll up the health of related systems into one status - useful per team, product, or environment."
68
+ steps={[
69
+ "Click “Add Group” and give it a meaningful name.",
70
+ "Add systems to the group from here or the Systems tab.",
71
+ "Subscribe to the group to alert your team on rolled-up incidents.",
72
+ ]}
73
+ actions={
74
+ <Button onClick={onAddGroup}>
75
+ <Plus className="mr-2 h-4 w-4" />
76
+ Add your first group
77
+ </Button>
78
+ }
79
+ />
80
+ </div>
81
+ );
82
+ }
83
+
84
+ return (
85
+ <div>
86
+ {header}
87
+ {groups.length === 0 ? (
88
+ <ListEmptyState
89
+ resource="groups"
90
+ description="No groups match the current search."
91
+ actions={
92
+ <Button variant="outline" onClick={props.onClearFilters}>
93
+ Clear filters
94
+ </Button>
95
+ }
96
+ />
97
+ ) : (
98
+ <div className="rounded-lg border border-border">
99
+ <Table>
100
+ <TableHeader>
101
+ <TableRow>
102
+ <TableHead className="w-64">Name</TableHead>
103
+ <TableHead>Systems</TableHead>
104
+ <TableHead className="w-px text-right">Actions</TableHead>
105
+ </TableRow>
106
+ </TableHeader>
107
+ <TableBody>
108
+ {groups.map((group) => (
109
+ <GroupRow
110
+ key={group.id}
111
+ group={group}
112
+ systemsById={systemsById}
113
+ allSystems={allSystems}
114
+ onDelete={props.onDeleteGroup}
115
+ onRename={props.onRenameGroup}
116
+ onAddToGroup={props.onAddToGroup}
117
+ onRemoveFromGroup={props.onRemoveFromGroup}
118
+ />
119
+ ))}
120
+ </TableBody>
121
+ </Table>
122
+ </div>
123
+ )}
124
+ </div>
125
+ );
126
+ }
127
+
128
+ interface GroupRowProps {
129
+ group: Group;
130
+ systemsById: Map<string, System>;
131
+ allSystems: System[];
132
+ onDelete: (id: string) => void;
133
+ onRename: (id: string, name: string) => void;
134
+ onAddToGroup: (systemId: string, groupId: string) => void;
135
+ onRemoveFromGroup: (groupId: string, systemId: string) => void;
136
+ }
137
+
138
+ function GroupRow({
139
+ group,
140
+ systemsById,
141
+ allSystems,
142
+ onDelete,
143
+ onRename,
144
+ onAddToGroup,
145
+ onRemoveFromGroup,
146
+ }: GroupRowProps): React.ReactElement {
147
+ const { isLocked, provenance } = useProvenanceLock({
148
+ kind: "Group",
149
+ entityId: group.id,
150
+ });
151
+ const [editing, setEditing] = useState(false);
152
+ const [draft, setDraft] = useState(group.name);
153
+
154
+ const memberIds = group.systemIds ?? [];
155
+ const members = memberIds
156
+ .map((id) => systemsById.get(id))
157
+ .filter((s): s is System => s !== undefined);
158
+ const available = allSystems.filter((s) => !memberIds.includes(s.id));
159
+ const lockTitle = isLocked ? "Managed by GitOps" : undefined;
160
+
161
+ const commitRename = (): void => {
162
+ const next = draft.trim();
163
+ if (next && next !== group.name) onRename(group.id, next);
164
+ else setDraft(group.name);
165
+ setEditing(false);
166
+ };
167
+
168
+ return (
169
+ <TableRow>
170
+ <TableCell className="align-top">
171
+ {editing ? (
172
+ <Input
173
+ autoFocus
174
+ value={draft}
175
+ onChange={(e) => setDraft(e.target.value)}
176
+ onBlur={commitRename}
177
+ onKeyDown={(e) => {
178
+ if (e.key === "Enter") commitRename();
179
+ if (e.key === "Escape") {
180
+ setDraft(group.name);
181
+ setEditing(false);
182
+ }
183
+ }}
184
+ className="h-8"
185
+ aria-label={`Rename ${group.name}`}
186
+ />
187
+ ) : (
188
+ <div className="flex items-center gap-2">
189
+ <span className="font-medium text-foreground">{group.name}</span>
190
+ {isLocked && provenance ? (
191
+ <GitOpsSourceBadge provenance={provenance} />
192
+ ) : (
193
+ <button
194
+ type="button"
195
+ aria-label={`Rename ${group.name}`}
196
+ className="text-muted-foreground hover:text-foreground"
197
+ onClick={() => {
198
+ setDraft(group.name);
199
+ setEditing(true);
200
+ }}
201
+ >
202
+ <Pencil className="h-3.5 w-3.5" />
203
+ </button>
204
+ )}
205
+ </div>
206
+ )}
207
+ </TableCell>
208
+ <TableCell>
209
+ <div className="flex flex-wrap items-center gap-1.5">
210
+ {members.length === 0 && (
211
+ <span className="text-xs text-muted-foreground">No systems</span>
212
+ )}
213
+ {members.map((system) => (
214
+ <span
215
+ key={system.id}
216
+ className="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground"
217
+ >
218
+ {system.name}
219
+ <button
220
+ type="button"
221
+ disabled={isLocked}
222
+ title={lockTitle ?? `Remove ${system.name}`}
223
+ aria-label={`Remove ${system.name} from ${group.name}`}
224
+ onClick={() => onRemoveFromGroup(group.id, system.id)}
225
+ className="text-muted-foreground hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
226
+ >
227
+ <X className="h-3 w-3" />
228
+ </button>
229
+ </span>
230
+ ))}
231
+ <AssignMenu
232
+ disabled={isLocked || available.length === 0}
233
+ triggerLabel={lockTitle ?? `Add a system to ${group.name}`}
234
+ trigger={
235
+ <>
236
+ <Plus className="h-3 w-3" />
237
+ System
238
+ </>
239
+ }
240
+ items={available.map((s) => ({ id: s.id, label: s.name }))}
241
+ emptyLabel="All systems added"
242
+ onSelect={(systemId) => onAddToGroup(systemId, group.id)}
243
+ />
244
+ </div>
245
+ </TableCell>
246
+ <TableCell>
247
+ <div className="flex items-center justify-end gap-1">
248
+ {editing && (
249
+ <Button
250
+ variant="ghost"
251
+ size="sm"
252
+ className="h-7 w-7 p-0"
253
+ aria-label="Save name"
254
+ onClick={commitRename}
255
+ >
256
+ <Check className="h-3.5 w-3.5" />
257
+ </Button>
258
+ )}
259
+ <Button
260
+ variant="ghost"
261
+ size="sm"
262
+ className="h-7 w-7 p-0 text-destructive hover:bg-destructive/10 hover:text-destructive/90"
263
+ disabled={isLocked}
264
+ title={lockTitle}
265
+ aria-label={`Delete ${group.name}`}
266
+ onClick={() => onDelete(group.id)}
267
+ >
268
+ <Trash2 className="h-3.5 w-3.5" />
269
+ </Button>
270
+ </div>
271
+ </TableCell>
272
+ </TableRow>
273
+ );
274
+ }
@@ -0,0 +1,430 @@
1
+ import { useMemo, useState } from "react";
2
+ import {
3
+ Table,
4
+ TableBody,
5
+ TableCell,
6
+ TableHead,
7
+ TableHeader,
8
+ TableRow,
9
+ Button,
10
+ Checkbox,
11
+ EmptyState,
12
+ ListEmptyState,
13
+ cn,
14
+ } from "@checkstack/ui";
15
+ import { ExtensionSlot } from "@checkstack/frontend-api";
16
+ import {
17
+ CatalogSystemActionsSlot,
18
+ SystemStateBadgesSlot,
19
+ } from "@checkstack/catalog-common";
20
+ import {
21
+ useProvenanceLock,
22
+ GitOpsSourceBadge,
23
+ } from "@checkstack/gitops-frontend";
24
+ import { Plus, Server, Edit, Trash2, X, Trash } from "lucide-react";
25
+ import type { Environment, Group, System } from "../../api";
26
+ import { AssignMenu } from "./AssignMenu";
27
+
28
+ export interface SystemsTabProps {
29
+ /** Systems after search/filter. */
30
+ systems: System[];
31
+ /** Total systems before filtering (distinguishes empty-catalog vs no-matches). */
32
+ totalCount: number;
33
+ allGroups: Group[];
34
+ allEnvironments: Environment[];
35
+ /** systemId -> the group ids it belongs to. */
36
+ systemGroupMap: Map<string, string[]>;
37
+ /** systemId -> the environment ids it's attached to. */
38
+ systemEnvMap: Map<string, string[]>;
39
+ onAddSystem: () => void;
40
+ onEditSystem: (system: System) => void;
41
+ onDeleteSystem: (id: string) => void;
42
+ onBulkDeleteSystems: (ids: string[]) => void;
43
+ onAddToGroup: (systemId: string, groupId: string) => void;
44
+ onRemoveFromGroup: (groupId: string, systemId: string) => void;
45
+ onAddToEnvironment: (systemId: string, environmentId: string) => void;
46
+ onRemoveFromEnvironment: (systemId: string, environmentId: string) => void;
47
+ onClearFilters: () => void;
48
+ }
49
+
50
+ export function SystemsTab(props: SystemsTabProps): React.ReactElement {
51
+ const {
52
+ systems,
53
+ totalCount,
54
+ allGroups,
55
+ allEnvironments,
56
+ systemGroupMap,
57
+ systemEnvMap,
58
+ onAddSystem,
59
+ onBulkDeleteSystems,
60
+ onAddToGroup,
61
+ onAddToEnvironment,
62
+ } = props;
63
+
64
+ const [selected, setSelected] = useState<Set<string>>(new Set());
65
+
66
+ const visibleIds = useMemo(() => systems.map((s) => s.id), [systems]);
67
+ const selectedVisible = visibleIds.filter((id) => selected.has(id));
68
+ const allSelected =
69
+ visibleIds.length > 0 && selectedVisible.length === visibleIds.length;
70
+
71
+ const toggle = (id: string): void =>
72
+ setSelected((prev) => {
73
+ const next = new Set(prev);
74
+ if (next.has(id)) next.delete(id);
75
+ else next.add(id);
76
+ return next;
77
+ });
78
+
79
+ const toggleAll = (): void =>
80
+ setSelected(allSelected ? new Set() : new Set(visibleIds));
81
+
82
+ const clearSelection = (): void => setSelected(new Set());
83
+
84
+ const header = (
85
+ <div className="mb-4 flex items-center justify-between gap-2">
86
+ <h2 className="flex items-center gap-2 text-lg font-semibold">
87
+ <Server className="h-5 w-5 text-muted-foreground" />
88
+ Systems
89
+ <span className="text-sm font-normal text-muted-foreground">
90
+ {totalCount}
91
+ </span>
92
+ </h2>
93
+ <Button size="sm" onClick={onAddSystem}>
94
+ <Plus className="mr-2 h-4 w-4" />
95
+ Add System
96
+ </Button>
97
+ </div>
98
+ );
99
+
100
+ if (totalCount === 0) {
101
+ return (
102
+ <div>
103
+ {header}
104
+ <EmptyState
105
+ icon={<Server className="size-10" />}
106
+ title="No systems yet"
107
+ description="Systems are the things you monitor. Add one, then attach health checks, SLOs, maintenance windows and incident history to it."
108
+ steps={[
109
+ "Click “Add System” to register your first service, host or job.",
110
+ "Group related systems so dashboards and on-call rotations stay tidy.",
111
+ "Wire health checks so a system's status reflects reality.",
112
+ ]}
113
+ actions={
114
+ <Button onClick={onAddSystem}>
115
+ <Plus className="mr-2 h-4 w-4" />
116
+ Add your first system
117
+ </Button>
118
+ }
119
+ />
120
+ </div>
121
+ );
122
+ }
123
+
124
+ return (
125
+ <div>
126
+ {header}
127
+
128
+ {selectedVisible.length > 0 && (
129
+ <div className="mb-3 flex items-center gap-3 rounded-md border border-border bg-muted/40 px-3 py-2">
130
+ <span className="text-sm font-medium">
131
+ {selectedVisible.length} selected
132
+ </span>
133
+ <AssignMenu
134
+ triggerLabel="Assign selected systems to a group"
135
+ trigger={<span>Assign to group</span>}
136
+ items={allGroups.map((g) => ({ id: g.id, label: g.name }))}
137
+ emptyLabel="No groups yet"
138
+ onSelect={(groupId) => {
139
+ for (const sysId of selectedVisible) {
140
+ if (!systemGroupMap.get(sysId)?.includes(groupId)) {
141
+ onAddToGroup(sysId, groupId);
142
+ }
143
+ }
144
+ clearSelection();
145
+ }}
146
+ />
147
+ <AssignMenu
148
+ triggerLabel="Attach selected systems to an environment"
149
+ trigger={<span>Add to environment</span>}
150
+ items={allEnvironments.map((e) => ({ id: e.id, label: e.name }))}
151
+ emptyLabel="No environments yet"
152
+ onSelect={(envId) => {
153
+ for (const sysId of selectedVisible) {
154
+ if (!systemEnvMap.get(sysId)?.includes(envId)) {
155
+ onAddToEnvironment(sysId, envId);
156
+ }
157
+ }
158
+ clearSelection();
159
+ }}
160
+ />
161
+ <Button
162
+ variant="ghost"
163
+ size="sm"
164
+ className="text-destructive hover:bg-destructive/10 hover:text-destructive/90"
165
+ onClick={() => {
166
+ onBulkDeleteSystems(selectedVisible);
167
+ clearSelection();
168
+ }}
169
+ >
170
+ <Trash className="mr-1.5 h-4 w-4" />
171
+ Delete
172
+ </Button>
173
+ </div>
174
+ )}
175
+
176
+ {systems.length === 0 ? (
177
+ <ListEmptyState
178
+ resource="systems"
179
+ description="No systems match the current search and filters."
180
+ actions={
181
+ <Button variant="outline" onClick={props.onClearFilters}>
182
+ Clear filters
183
+ </Button>
184
+ }
185
+ />
186
+ ) : (
187
+ <div className="rounded-lg border border-border">
188
+ <Table>
189
+ <TableHeader>
190
+ <TableRow>
191
+ <TableHead className="w-10">
192
+ <Checkbox
193
+ checked={allSelected}
194
+ onCheckedChange={toggleAll}
195
+ aria-label="Select all systems"
196
+ />
197
+ </TableHead>
198
+ <TableHead>Name</TableHead>
199
+ <TableHead className="w-44">Health</TableHead>
200
+ <TableHead>Groups</TableHead>
201
+ <TableHead>Environments</TableHead>
202
+ <TableHead className="w-px text-right">Actions</TableHead>
203
+ </TableRow>
204
+ </TableHeader>
205
+ <TableBody>
206
+ {systems.map((system) => (
207
+ <SystemRow
208
+ key={system.id}
209
+ system={system}
210
+ allGroups={allGroups}
211
+ allEnvironments={allEnvironments}
212
+ assignedGroupIds={systemGroupMap.get(system.id) ?? []}
213
+ assignedEnvIds={systemEnvMap.get(system.id) ?? []}
214
+ selected={selected.has(system.id)}
215
+ onToggleSelected={() => toggle(system.id)}
216
+ onEdit={props.onEditSystem}
217
+ onDelete={props.onDeleteSystem}
218
+ onAddToGroup={props.onAddToGroup}
219
+ onRemoveFromGroup={props.onRemoveFromGroup}
220
+ onAddToEnvironment={props.onAddToEnvironment}
221
+ onRemoveFromEnvironment={props.onRemoveFromEnvironment}
222
+ />
223
+ ))}
224
+ </TableBody>
225
+ </Table>
226
+ </div>
227
+ )}
228
+ </div>
229
+ );
230
+ }
231
+
232
+ interface SystemRowProps {
233
+ system: System;
234
+ allGroups: Group[];
235
+ allEnvironments: Environment[];
236
+ assignedGroupIds: string[];
237
+ assignedEnvIds: string[];
238
+ selected: boolean;
239
+ onToggleSelected: () => void;
240
+ onEdit: (system: System) => void;
241
+ onDelete: (id: string) => void;
242
+ onAddToGroup: (systemId: string, groupId: string) => void;
243
+ onRemoveFromGroup: (groupId: string, systemId: string) => void;
244
+ onAddToEnvironment: (systemId: string, environmentId: string) => void;
245
+ onRemoveFromEnvironment: (systemId: string, environmentId: string) => void;
246
+ }
247
+
248
+ /** A removable membership chip. */
249
+ function Chip({
250
+ label,
251
+ onRemove,
252
+ removeLabel,
253
+ disabled,
254
+ disabledTitle,
255
+ }: {
256
+ label: string;
257
+ onRemove: () => void;
258
+ removeLabel: string;
259
+ disabled?: boolean;
260
+ disabledTitle?: string;
261
+ }): React.ReactElement {
262
+ return (
263
+ <span className="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
264
+ {label}
265
+ <button
266
+ type="button"
267
+ disabled={disabled}
268
+ title={disabled ? disabledTitle : removeLabel}
269
+ aria-label={removeLabel}
270
+ onClick={onRemove}
271
+ className="text-muted-foreground hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
272
+ >
273
+ <X className="h-3 w-3" />
274
+ </button>
275
+ </span>
276
+ );
277
+ }
278
+
279
+ function SystemRow({
280
+ system,
281
+ allGroups,
282
+ allEnvironments,
283
+ assignedGroupIds,
284
+ assignedEnvIds,
285
+ selected,
286
+ onToggleSelected,
287
+ onEdit,
288
+ onDelete,
289
+ onAddToGroup,
290
+ onRemoveFromGroup,
291
+ onAddToEnvironment,
292
+ onRemoveFromEnvironment,
293
+ }: SystemRowProps): React.ReactElement {
294
+ const { isLocked, provenance } = useProvenanceLock({
295
+ kind: "System",
296
+ entityId: system.id,
297
+ });
298
+
299
+ const assignedGroups = allGroups.filter((g) =>
300
+ assignedGroupIds.includes(g.id),
301
+ );
302
+ const availableGroups = allGroups.filter(
303
+ (g) => !assignedGroupIds.includes(g.id),
304
+ );
305
+ const assignedEnvs = allEnvironments.filter((e) =>
306
+ assignedEnvIds.includes(e.id),
307
+ );
308
+ const availableEnvs = allEnvironments.filter(
309
+ (e) => !assignedEnvIds.includes(e.id),
310
+ );
311
+ const lockTitle = isLocked ? "Managed by GitOps" : undefined;
312
+
313
+ return (
314
+ <TableRow data-state={selected ? "selected" : undefined}>
315
+ <TableCell>
316
+ <Checkbox
317
+ checked={selected}
318
+ onCheckedChange={onToggleSelected}
319
+ aria-label={`Select ${system.name}`}
320
+ />
321
+ </TableCell>
322
+ <TableCell>
323
+ <div className="flex items-center gap-2">
324
+ <div className="min-w-0">
325
+ <p className="font-medium leading-snug text-foreground">
326
+ {system.name}
327
+ </p>
328
+ {system.description && (
329
+ <p className="truncate text-xs text-muted-foreground">
330
+ {system.description}
331
+ </p>
332
+ )}
333
+ </div>
334
+ {isLocked && provenance && (
335
+ <GitOpsSourceBadge provenance={provenance} />
336
+ )}
337
+ </div>
338
+ </TableCell>
339
+ <TableCell>
340
+ <div className="flex flex-wrap items-center gap-1">
341
+ <ExtensionSlot slot={SystemStateBadgesSlot} context={{ system }} />
342
+ </div>
343
+ </TableCell>
344
+ <TableCell>
345
+ <div className="flex flex-wrap items-center gap-1.5">
346
+ {assignedGroups.map((group) => (
347
+ <Chip
348
+ key={group.id}
349
+ label={group.name}
350
+ removeLabel={`Remove ${system.name} from ${group.name}`}
351
+ disabled={isLocked}
352
+ disabledTitle={lockTitle}
353
+ onRemove={() => onRemoveFromGroup(group.id, system.id)}
354
+ />
355
+ ))}
356
+ <AssignMenu
357
+ disabled={isLocked || availableGroups.length === 0}
358
+ triggerLabel={lockTitle ?? `Add ${system.name} to a group`}
359
+ trigger={
360
+ <>
361
+ <Plus className="h-3 w-3" />
362
+ Group
363
+ </>
364
+ }
365
+ items={availableGroups.map((g) => ({ id: g.id, label: g.name }))}
366
+ emptyLabel="No more groups"
367
+ onSelect={(groupId) => onAddToGroup(system.id, groupId)}
368
+ />
369
+ </div>
370
+ </TableCell>
371
+ <TableCell>
372
+ <div className="flex flex-wrap items-center gap-1.5">
373
+ {assignedEnvs.map((env) => (
374
+ <Chip
375
+ key={env.id}
376
+ label={env.name}
377
+ removeLabel={`Remove ${system.name} from ${env.name}`}
378
+ onRemove={() => onRemoveFromEnvironment(system.id, env.id)}
379
+ />
380
+ ))}
381
+ <AssignMenu
382
+ disabled={availableEnvs.length === 0}
383
+ triggerLabel={`Attach ${system.name} to an environment`}
384
+ trigger={
385
+ <>
386
+ <Plus className="h-3 w-3" />
387
+ Environment
388
+ </>
389
+ }
390
+ items={availableEnvs.map((e) => ({ id: e.id, label: e.name }))}
391
+ emptyLabel="No more environments"
392
+ onSelect={(envId) => onAddToEnvironment(system.id, envId)}
393
+ />
394
+ </div>
395
+ </TableCell>
396
+ <TableCell>
397
+ <div className="flex items-center justify-end gap-1">
398
+ <ExtensionSlot
399
+ slot={CatalogSystemActionsSlot}
400
+ context={{ systemId: system.id, systemName: system.name }}
401
+ />
402
+ <Button
403
+ variant="ghost"
404
+ size="sm"
405
+ className="h-7 w-7 p-0"
406
+ disabled={isLocked}
407
+ title={lockTitle}
408
+ aria-label={`Edit ${system.name}`}
409
+ onClick={() => onEdit(system)}
410
+ >
411
+ <Edit className="h-3.5 w-3.5" />
412
+ </Button>
413
+ <Button
414
+ variant="ghost"
415
+ size="sm"
416
+ className={cn(
417
+ "h-7 w-7 p-0 text-destructive hover:bg-destructive/10 hover:text-destructive/90",
418
+ )}
419
+ disabled={isLocked}
420
+ title={lockTitle}
421
+ aria-label={`Delete ${system.name}`}
422
+ onClick={() => onDelete(system.id)}
423
+ >
424
+ <Trash2 className="h-3.5 w-3.5" />
425
+ </Button>
426
+ </div>
427
+ </TableCell>
428
+ </TableRow>
429
+ );
430
+ }