@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
|
@@ -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
|
+
}
|