@checkstack/catalog-frontend 0.0.2
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 +116 -0
- package/package.json +28 -0
- package/src/api.ts +12 -0
- package/src/components/CatalogConfigPage.tsx +490 -0
- package/src/components/CatalogPage.tsx +19 -0
- package/src/components/GroupEditor.tsx +94 -0
- package/src/components/SystemDetailPage.tsx +314 -0
- package/src/components/SystemEditor.tsx +113 -0
- package/src/components/UserMenuItems.tsx +33 -0
- package/src/index.tsx +56 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# @checkstack/catalog-frontend
|
|
2
|
+
|
|
3
|
+
## 0.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
|
|
8
|
+
- Updated dependencies [d20d274]
|
|
9
|
+
- @checkstack/auth-frontend@0.0.2
|
|
10
|
+
- @checkstack/catalog-common@0.0.2
|
|
11
|
+
- @checkstack/common@0.0.2
|
|
12
|
+
- @checkstack/frontend-api@0.0.2
|
|
13
|
+
- @checkstack/notification-common@0.0.2
|
|
14
|
+
- @checkstack/ui@0.0.2
|
|
15
|
+
|
|
16
|
+
## 0.1.0
|
|
17
|
+
|
|
18
|
+
### Minor Changes
|
|
19
|
+
|
|
20
|
+
- a65e002: Add command palette commands and deep-linking support
|
|
21
|
+
|
|
22
|
+
**Backend Changes:**
|
|
23
|
+
|
|
24
|
+
- `healthcheck-backend`: Add "Manage Health Checks" (⇧⌘H) and "Create Health Check" commands
|
|
25
|
+
- `catalog-backend`: Add "Manage Systems" (⇧⌘S) and "Create System" commands
|
|
26
|
+
- `integration-backend`: Add "Manage Integrations" (⇧⌘G), "Create Integration Subscription", and "View Integration Logs" commands
|
|
27
|
+
- `auth-backend`: Add "Manage Users" (⇧⌘U), "Create User", "Manage Roles", and "Manage Applications" commands
|
|
28
|
+
- `command-backend`: Auto-cleanup command registrations when plugins are deregistered
|
|
29
|
+
|
|
30
|
+
**Frontend Changes:**
|
|
31
|
+
|
|
32
|
+
- `HealthCheckConfigPage`: Handle `?action=create` URL parameter
|
|
33
|
+
- `CatalogConfigPage`: Handle `?action=create` URL parameter
|
|
34
|
+
- `IntegrationsPage`: Handle `?action=create` URL parameter
|
|
35
|
+
- `AuthSettingsPage`: Handle `?tab=` and `?action=create` URL parameters
|
|
36
|
+
|
|
37
|
+
### Patch Changes
|
|
38
|
+
|
|
39
|
+
- b0124ef: Fix light mode contrast for semantic color tokens
|
|
40
|
+
|
|
41
|
+
Updated the theme system to use a two-tier pattern for semantic colors:
|
|
42
|
+
|
|
43
|
+
- Base tokens (`text-destructive`, `text-success`, etc.) are used for text on light backgrounds (`bg-{color}/10`)
|
|
44
|
+
- Foreground tokens (`text-destructive-foreground`, etc.) are now white/contrasting and used for text on solid backgrounds
|
|
45
|
+
|
|
46
|
+
This fixes poor contrast issues with components like the "Incident" badge which had dark red text on a bright red background in light mode.
|
|
47
|
+
|
|
48
|
+
Components updated: Alert, InfoBanner, HealthBadge, Badge, PermissionDenied, SystemDetailPage
|
|
49
|
+
|
|
50
|
+
- 32ea706: ### User Menu Loading State Fix
|
|
51
|
+
|
|
52
|
+
Fixed user menu items "popping in" one after another due to independent async permission checks.
|
|
53
|
+
|
|
54
|
+
**Changes:**
|
|
55
|
+
|
|
56
|
+
- Added `UserMenuItemsContext` interface with `permissions` and `hasCredentialAccount` to `@checkstack/frontend-api`
|
|
57
|
+
- `LoginNavbarAction` now pre-fetches all permissions and credential account info before rendering the menu
|
|
58
|
+
- All user menu item components now use the passed context for synchronous permission checks instead of async hooks
|
|
59
|
+
- Uses `qualifyPermissionId` helper for fully-qualified permission IDs
|
|
60
|
+
|
|
61
|
+
**Result:** All menu items appear simultaneously when the user menu opens.
|
|
62
|
+
|
|
63
|
+
- Updated dependencies [52231ef]
|
|
64
|
+
- Updated dependencies [b0124ef]
|
|
65
|
+
- Updated dependencies [54cc787]
|
|
66
|
+
- Updated dependencies [a65e002]
|
|
67
|
+
- Updated dependencies [ae33df2]
|
|
68
|
+
- Updated dependencies [a65e002]
|
|
69
|
+
- Updated dependencies [32ea706]
|
|
70
|
+
- @checkstack/auth-frontend@0.3.0
|
|
71
|
+
- @checkstack/ui@0.1.2
|
|
72
|
+
- @checkstack/common@0.2.0
|
|
73
|
+
- @checkstack/frontend-api@0.1.0
|
|
74
|
+
- @checkstack/catalog-common@0.1.2
|
|
75
|
+
- @checkstack/notification-common@0.1.1
|
|
76
|
+
|
|
77
|
+
## 0.0.5
|
|
78
|
+
|
|
79
|
+
### Patch Changes
|
|
80
|
+
|
|
81
|
+
- Updated dependencies [1bf71bb]
|
|
82
|
+
- @checkstack/auth-frontend@0.2.1
|
|
83
|
+
|
|
84
|
+
## 0.0.4
|
|
85
|
+
|
|
86
|
+
### Patch Changes
|
|
87
|
+
|
|
88
|
+
- Updated dependencies [e26c08e]
|
|
89
|
+
- @checkstack/auth-frontend@0.2.0
|
|
90
|
+
|
|
91
|
+
## 0.0.3
|
|
92
|
+
|
|
93
|
+
### Patch Changes
|
|
94
|
+
|
|
95
|
+
- Updated dependencies [0f8cc7d]
|
|
96
|
+
- @checkstack/frontend-api@0.0.3
|
|
97
|
+
- @checkstack/auth-frontend@0.1.1
|
|
98
|
+
- @checkstack/catalog-common@0.1.1
|
|
99
|
+
- @checkstack/ui@0.1.1
|
|
100
|
+
|
|
101
|
+
## 0.0.2
|
|
102
|
+
|
|
103
|
+
### Patch Changes
|
|
104
|
+
|
|
105
|
+
- Updated dependencies [eff5b4e]
|
|
106
|
+
- Updated dependencies [ffc28f6]
|
|
107
|
+
- Updated dependencies [4dd644d]
|
|
108
|
+
- Updated dependencies [32f2535]
|
|
109
|
+
- Updated dependencies [b55fae6]
|
|
110
|
+
- Updated dependencies [b354ab3]
|
|
111
|
+
- @checkstack/ui@0.1.0
|
|
112
|
+
- @checkstack/common@0.1.0
|
|
113
|
+
- @checkstack/catalog-common@0.1.0
|
|
114
|
+
- @checkstack/notification-common@0.1.0
|
|
115
|
+
- @checkstack/auth-frontend@0.1.0
|
|
116
|
+
- @checkstack/frontend-api@0.0.2
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/catalog-frontend",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.tsx",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"typecheck": "tsc --noEmit",
|
|
8
|
+
"lint": "bun run lint:code",
|
|
9
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@checkstack/catalog-common": "workspace:*",
|
|
13
|
+
"@checkstack/frontend-api": "workspace:*",
|
|
14
|
+
"@checkstack/auth-frontend": "workspace:*",
|
|
15
|
+
"@checkstack/common": "workspace:*",
|
|
16
|
+
"@checkstack/notification-common": "workspace:*",
|
|
17
|
+
"@checkstack/ui": "workspace:*",
|
|
18
|
+
"react": "^18.2.0",
|
|
19
|
+
"react-router-dom": "^6.22.0",
|
|
20
|
+
"lucide-react": "^0.344.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"typescript": "^5.0.0",
|
|
24
|
+
"@types/react": "^18.2.0",
|
|
25
|
+
"@checkstack/tsconfig": "workspace:*",
|
|
26
|
+
"@checkstack/scripts": "workspace:*"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createApiRef } from "@checkstack/frontend-api";
|
|
2
|
+
import { CatalogApi } from "@checkstack/catalog-common";
|
|
3
|
+
import type { InferClient } from "@checkstack/common";
|
|
4
|
+
|
|
5
|
+
// Re-export types for convenience
|
|
6
|
+
export type { System, Group, View } from "@checkstack/catalog-common";
|
|
7
|
+
|
|
8
|
+
// CatalogApi client type inferred from the client definition
|
|
9
|
+
export type CatalogApiClient = InferClient<typeof CatalogApi>;
|
|
10
|
+
|
|
11
|
+
export const catalogApiRef =
|
|
12
|
+
createApiRef<CatalogApiClient>("plugin.catalog.api");
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { useSearchParams } from "react-router-dom";
|
|
3
|
+
import {
|
|
4
|
+
useApi,
|
|
5
|
+
permissionApiRef,
|
|
6
|
+
ExtensionSlot,
|
|
7
|
+
} from "@checkstack/frontend-api";
|
|
8
|
+
import { catalogApiRef, System, Group } from "../api";
|
|
9
|
+
import { CatalogSystemActionsSlot } from "@checkstack/catalog-common";
|
|
10
|
+
import {
|
|
11
|
+
SectionHeader,
|
|
12
|
+
Card,
|
|
13
|
+
CardHeader,
|
|
14
|
+
CardTitle,
|
|
15
|
+
CardContent,
|
|
16
|
+
Button,
|
|
17
|
+
Label,
|
|
18
|
+
LoadingSpinner,
|
|
19
|
+
EmptyState,
|
|
20
|
+
PermissionDenied,
|
|
21
|
+
EditableText,
|
|
22
|
+
ConfirmationModal,
|
|
23
|
+
useToast,
|
|
24
|
+
} from "@checkstack/ui";
|
|
25
|
+
import { Plus, Trash2, LayoutGrid, Server, Settings } from "lucide-react";
|
|
26
|
+
import { SystemEditor } from "./SystemEditor";
|
|
27
|
+
import { GroupEditor } from "./GroupEditor";
|
|
28
|
+
|
|
29
|
+
export const CatalogConfigPage = () => {
|
|
30
|
+
const catalogApi = useApi(catalogApiRef);
|
|
31
|
+
const permissionApi = useApi(permissionApiRef);
|
|
32
|
+
const toast = useToast();
|
|
33
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
34
|
+
const { allowed: canManage, loading: permissionLoading } =
|
|
35
|
+
permissionApi.useManagePermission("catalog");
|
|
36
|
+
|
|
37
|
+
const [systems, setSystems] = useState<System[]>([]);
|
|
38
|
+
const [groups, setGroups] = useState<Group[]>([]);
|
|
39
|
+
const [loading, setLoading] = useState(true);
|
|
40
|
+
|
|
41
|
+
// Dialog state
|
|
42
|
+
const [isSystemEditorOpen, setIsSystemEditorOpen] = useState(false);
|
|
43
|
+
const [isGroupEditorOpen, setIsGroupEditorOpen] = useState(false);
|
|
44
|
+
|
|
45
|
+
const [selectedGroupId, setSelectedGroupId] = useState("");
|
|
46
|
+
const [selectedSystemToAdd, setSelectedSystemToAdd] = useState("");
|
|
47
|
+
|
|
48
|
+
// Confirmation modal state
|
|
49
|
+
const [confirmModal, setConfirmModal] = useState<{
|
|
50
|
+
isOpen: boolean;
|
|
51
|
+
title: string;
|
|
52
|
+
message: string;
|
|
53
|
+
onConfirm: () => void;
|
|
54
|
+
}>({
|
|
55
|
+
isOpen: false,
|
|
56
|
+
title: "",
|
|
57
|
+
message: "",
|
|
58
|
+
onConfirm: () => {},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const loadData = async () => {
|
|
62
|
+
setLoading(true);
|
|
63
|
+
try {
|
|
64
|
+
const [s, g] = await Promise.all([
|
|
65
|
+
catalogApi.getSystems(),
|
|
66
|
+
catalogApi.getGroups(),
|
|
67
|
+
]);
|
|
68
|
+
setSystems(s);
|
|
69
|
+
setGroups(g);
|
|
70
|
+
if (g.length > 0 && !selectedGroupId) {
|
|
71
|
+
setSelectedGroupId(g[0].id);
|
|
72
|
+
}
|
|
73
|
+
} catch (error) {
|
|
74
|
+
const message =
|
|
75
|
+
error instanceof Error ? error.message : "Failed to load catalog data";
|
|
76
|
+
toast.error(message);
|
|
77
|
+
console.error("Failed to load catalog data:", error);
|
|
78
|
+
} finally {
|
|
79
|
+
setLoading(false);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
loadData();
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
// Handle ?action=create URL parameter (from command palette)
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (searchParams.get("action") === "create" && canManage) {
|
|
90
|
+
setIsSystemEditorOpen(true);
|
|
91
|
+
// Clear the URL param after opening
|
|
92
|
+
searchParams.delete("action");
|
|
93
|
+
setSearchParams(searchParams, { replace: true });
|
|
94
|
+
}
|
|
95
|
+
}, [searchParams, canManage, setSearchParams]);
|
|
96
|
+
|
|
97
|
+
const handleCreateSystem = async (data: {
|
|
98
|
+
name: string;
|
|
99
|
+
description?: string;
|
|
100
|
+
}) => {
|
|
101
|
+
await catalogApi.createSystem(data);
|
|
102
|
+
toast.success("System created successfully");
|
|
103
|
+
await loadData();
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const handleCreateGroup = async (data: { name: string }) => {
|
|
107
|
+
await catalogApi.createGroup(data);
|
|
108
|
+
toast.success("Group created successfully");
|
|
109
|
+
await loadData();
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const handleDeleteSystem = async (id: string) => {
|
|
113
|
+
const system = systems.find((s) => s.id === id);
|
|
114
|
+
setConfirmModal({
|
|
115
|
+
isOpen: true,
|
|
116
|
+
title: "Delete System",
|
|
117
|
+
message: `Are you sure you want to delete "${system?.name}"? This will remove the system from all groups as well.`,
|
|
118
|
+
onConfirm: async () => {
|
|
119
|
+
try {
|
|
120
|
+
await catalogApi.deleteSystem(id);
|
|
121
|
+
setConfirmModal({ ...confirmModal, isOpen: false });
|
|
122
|
+
toast.success("System deleted successfully");
|
|
123
|
+
loadData();
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const message =
|
|
126
|
+
error instanceof Error ? error.message : "Failed to delete system";
|
|
127
|
+
toast.error(message);
|
|
128
|
+
console.error("Failed to delete system:", error);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const handleDeleteGroup = async (id: string) => {
|
|
135
|
+
const group = groups.find((g) => g.id === id);
|
|
136
|
+
setConfirmModal({
|
|
137
|
+
isOpen: true,
|
|
138
|
+
title: "Delete Group",
|
|
139
|
+
message: `Are you sure you want to delete "${group?.name}"? This action cannot be undone.`,
|
|
140
|
+
onConfirm: async () => {
|
|
141
|
+
try {
|
|
142
|
+
await catalogApi.deleteGroup(id);
|
|
143
|
+
setConfirmModal({ ...confirmModal, isOpen: false });
|
|
144
|
+
toast.success("Group deleted successfully");
|
|
145
|
+
loadData();
|
|
146
|
+
} catch (error) {
|
|
147
|
+
const message =
|
|
148
|
+
error instanceof Error ? error.message : "Failed to delete group";
|
|
149
|
+
toast.error(message);
|
|
150
|
+
console.error("Failed to delete group:", error);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const handleAddSystemToGroup = async () => {
|
|
157
|
+
if (!selectedGroupId || !selectedSystemToAdd) return;
|
|
158
|
+
try {
|
|
159
|
+
await catalogApi.addSystemToGroup({
|
|
160
|
+
groupId: selectedGroupId,
|
|
161
|
+
systemId: selectedSystemToAdd,
|
|
162
|
+
});
|
|
163
|
+
setSelectedSystemToAdd("");
|
|
164
|
+
toast.success("System added to group successfully");
|
|
165
|
+
loadData();
|
|
166
|
+
} catch (error) {
|
|
167
|
+
const message =
|
|
168
|
+
error instanceof Error
|
|
169
|
+
? error.message
|
|
170
|
+
: "Failed to add system to group";
|
|
171
|
+
toast.error(message);
|
|
172
|
+
console.error("Failed to add system to group:", error);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const handleRemoveSystemFromGroup = async (
|
|
177
|
+
groupId: string,
|
|
178
|
+
systemId: string
|
|
179
|
+
) => {
|
|
180
|
+
try {
|
|
181
|
+
await catalogApi.removeSystemFromGroup({ groupId, systemId });
|
|
182
|
+
toast.success("System removed from group successfully");
|
|
183
|
+
loadData();
|
|
184
|
+
} catch (error) {
|
|
185
|
+
const message =
|
|
186
|
+
error instanceof Error
|
|
187
|
+
? error.message
|
|
188
|
+
: "Failed to remove system from group";
|
|
189
|
+
toast.error(message);
|
|
190
|
+
console.error("Failed to remove system from group:", error);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const handleUpdateSystemName = async (id: string, newName: string) => {
|
|
195
|
+
try {
|
|
196
|
+
await catalogApi.updateSystem({ id, data: { name: newName } });
|
|
197
|
+
toast.success("System name updated successfully");
|
|
198
|
+
loadData();
|
|
199
|
+
} catch (error) {
|
|
200
|
+
const message =
|
|
201
|
+
error instanceof Error ? error.message : "Failed to update system name";
|
|
202
|
+
toast.error(message);
|
|
203
|
+
console.error("Failed to update system name:", error);
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const handleUpdateSystemDescription = async (
|
|
209
|
+
id: string,
|
|
210
|
+
newDescription: string
|
|
211
|
+
) => {
|
|
212
|
+
try {
|
|
213
|
+
await catalogApi.updateSystem({
|
|
214
|
+
id,
|
|
215
|
+
data: { description: newDescription },
|
|
216
|
+
});
|
|
217
|
+
toast.success("System description updated successfully");
|
|
218
|
+
loadData();
|
|
219
|
+
} catch (error) {
|
|
220
|
+
const message =
|
|
221
|
+
error instanceof Error
|
|
222
|
+
? error.message
|
|
223
|
+
: "Failed to update system description";
|
|
224
|
+
toast.error(message);
|
|
225
|
+
console.error("Failed to update system description:", error);
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const handleUpdateGroupName = async (id: string, newName: string) => {
|
|
231
|
+
try {
|
|
232
|
+
await catalogApi.updateGroup({ id, data: { name: newName } });
|
|
233
|
+
toast.success("Group name updated successfully");
|
|
234
|
+
loadData();
|
|
235
|
+
} catch (error) {
|
|
236
|
+
const message =
|
|
237
|
+
error instanceof Error ? error.message : "Failed to update group name";
|
|
238
|
+
toast.error(message);
|
|
239
|
+
console.error("Failed to update group name:", error);
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
if (loading || permissionLoading) return <LoadingSpinner />;
|
|
245
|
+
|
|
246
|
+
if (!canManage) {
|
|
247
|
+
return <PermissionDenied />;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const selectedGroup = groups.find((g) => g.id === selectedGroupId);
|
|
251
|
+
const availableSystems = systems.filter(
|
|
252
|
+
(s) => !selectedGroup?.systemIds?.includes(s.id)
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<div className="space-y-8">
|
|
257
|
+
<SectionHeader
|
|
258
|
+
title="Catalog Management"
|
|
259
|
+
description="Manage systems and logical groups within your infrastructure"
|
|
260
|
+
icon={<Settings className="w-6 h-6 text-primary" />}
|
|
261
|
+
/>
|
|
262
|
+
|
|
263
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
264
|
+
{/* Systems Management */}
|
|
265
|
+
<Card>
|
|
266
|
+
<CardHeader className="flex flex-row items-center justify-between">
|
|
267
|
+
<CardTitle className="flex items-center gap-2">
|
|
268
|
+
<Server className="w-5 h-5 text-muted-foreground" />
|
|
269
|
+
Systems
|
|
270
|
+
</CardTitle>
|
|
271
|
+
<Button size="sm" onClick={() => setIsSystemEditorOpen(true)}>
|
|
272
|
+
<Plus className="w-4 h-4 mr-2" />
|
|
273
|
+
Add System
|
|
274
|
+
</Button>
|
|
275
|
+
</CardHeader>
|
|
276
|
+
<CardContent className="space-y-4">
|
|
277
|
+
{systems.length === 0 ? (
|
|
278
|
+
<EmptyState title="No systems created yet." />
|
|
279
|
+
) : (
|
|
280
|
+
<div className="space-y-2">
|
|
281
|
+
{systems.map((system) => (
|
|
282
|
+
<div
|
|
283
|
+
key={system.id}
|
|
284
|
+
className="flex items-start justify-between p-3 bg-muted/30 rounded-lg border border-border"
|
|
285
|
+
>
|
|
286
|
+
<div className="flex-1 space-y-1">
|
|
287
|
+
<div className="flex items-center justify-between">
|
|
288
|
+
<EditableText
|
|
289
|
+
value={system.name}
|
|
290
|
+
onSave={(newName) =>
|
|
291
|
+
handleUpdateSystemName(system.id, newName)
|
|
292
|
+
}
|
|
293
|
+
className="font-medium text-foreground"
|
|
294
|
+
/>
|
|
295
|
+
<ExtensionSlot
|
|
296
|
+
slot={CatalogSystemActionsSlot}
|
|
297
|
+
context={{
|
|
298
|
+
systemId: system.id,
|
|
299
|
+
systemName: system.name,
|
|
300
|
+
}}
|
|
301
|
+
/>
|
|
302
|
+
</div>
|
|
303
|
+
<EditableText
|
|
304
|
+
value={system.description || "No description"}
|
|
305
|
+
onSave={(newDescription) =>
|
|
306
|
+
handleUpdateSystemDescription(
|
|
307
|
+
system.id,
|
|
308
|
+
newDescription
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
className="text-xs text-muted-foreground font-mono"
|
|
312
|
+
placeholder="Add description..."
|
|
313
|
+
/>
|
|
314
|
+
</div>
|
|
315
|
+
<Button
|
|
316
|
+
variant="ghost"
|
|
317
|
+
className="text-destructive hover:text-destructive/90 hover:bg-destructive/10 h-8 w-8 p-0"
|
|
318
|
+
onClick={() => handleDeleteSystem(system.id)}
|
|
319
|
+
>
|
|
320
|
+
<Trash2 className="w-4 h-4" />
|
|
321
|
+
</Button>
|
|
322
|
+
</div>
|
|
323
|
+
))}
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
</CardContent>
|
|
327
|
+
</Card>
|
|
328
|
+
|
|
329
|
+
{/* Groups Management */}
|
|
330
|
+
<Card>
|
|
331
|
+
<CardHeader className="flex flex-row items-center justify-between">
|
|
332
|
+
<CardTitle className="flex items-center gap-2">
|
|
333
|
+
<LayoutGrid className="w-5 h-5 text-muted-foreground" />
|
|
334
|
+
Groups
|
|
335
|
+
</CardTitle>
|
|
336
|
+
<Button size="sm" onClick={() => setIsGroupEditorOpen(true)}>
|
|
337
|
+
<Plus className="w-4 h-4 mr-2" />
|
|
338
|
+
Add Group
|
|
339
|
+
</Button>
|
|
340
|
+
</CardHeader>
|
|
341
|
+
<CardContent className="space-y-4">
|
|
342
|
+
{groups.length === 0 ? (
|
|
343
|
+
<EmptyState title="No groups created yet." />
|
|
344
|
+
) : (
|
|
345
|
+
<div className="space-y-2">
|
|
346
|
+
{groups.map((group) => (
|
|
347
|
+
<div
|
|
348
|
+
key={group.id}
|
|
349
|
+
className="p-3 bg-muted/30 rounded-lg border border-border space-y-2"
|
|
350
|
+
>
|
|
351
|
+
<div className="flex items-center justify-between">
|
|
352
|
+
<div className="flex-1">
|
|
353
|
+
<EditableText
|
|
354
|
+
value={group.name}
|
|
355
|
+
onSave={(newName) =>
|
|
356
|
+
handleUpdateGroupName(group.id, newName)
|
|
357
|
+
}
|
|
358
|
+
className="font-medium text-foreground"
|
|
359
|
+
/>
|
|
360
|
+
<p className="text-xs text-muted-foreground font-mono">
|
|
361
|
+
{group.id}
|
|
362
|
+
</p>
|
|
363
|
+
</div>
|
|
364
|
+
<Button
|
|
365
|
+
variant="ghost"
|
|
366
|
+
className="text-destructive hover:text-destructive/90 hover:bg-destructive/10 h-8 w-8 p-0"
|
|
367
|
+
onClick={() => handleDeleteGroup(group.id)}
|
|
368
|
+
>
|
|
369
|
+
<Trash2 className="w-4 h-4" />
|
|
370
|
+
</Button>
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
{/* Systems in this group */}
|
|
374
|
+
{group.systemIds && group.systemIds.length > 0 && (
|
|
375
|
+
<div className="pl-4 space-y-1">
|
|
376
|
+
{group.systemIds
|
|
377
|
+
.map((sysId) => systems.find((s) => s.id === sysId))
|
|
378
|
+
.filter((sys): sys is System => !!sys)
|
|
379
|
+
.map((sys) => (
|
|
380
|
+
<div
|
|
381
|
+
key={sys.id}
|
|
382
|
+
className="flex items-center justify-between text-sm bg-background p-2 rounded border border-border"
|
|
383
|
+
>
|
|
384
|
+
<span className="text-foreground">
|
|
385
|
+
{sys.name}
|
|
386
|
+
</span>
|
|
387
|
+
<Button
|
|
388
|
+
variant="ghost"
|
|
389
|
+
className="text-destructive/60 hover:text-destructive h-6 w-6 p-0"
|
|
390
|
+
onClick={() =>
|
|
391
|
+
handleRemoveSystemFromGroup(group.id, sys.id)
|
|
392
|
+
}
|
|
393
|
+
>
|
|
394
|
+
<Trash2 className="w-3 h-3" />
|
|
395
|
+
</Button>
|
|
396
|
+
</div>
|
|
397
|
+
))}
|
|
398
|
+
</div>
|
|
399
|
+
)}
|
|
400
|
+
</div>
|
|
401
|
+
))}
|
|
402
|
+
</div>
|
|
403
|
+
)}
|
|
404
|
+
</CardContent>
|
|
405
|
+
</Card>
|
|
406
|
+
</div>
|
|
407
|
+
|
|
408
|
+
{/* Add System to Group Section */}
|
|
409
|
+
{groups.length > 0 && systems.length > 0 && (
|
|
410
|
+
<Card>
|
|
411
|
+
<CardHeader>
|
|
412
|
+
<CardTitle>Add System to Group</CardTitle>
|
|
413
|
+
</CardHeader>
|
|
414
|
+
<CardContent className="space-y-4">
|
|
415
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
416
|
+
<div className="space-y-2">
|
|
417
|
+
<Label>Select Group</Label>
|
|
418
|
+
<select
|
|
419
|
+
className="w-full flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
420
|
+
value={selectedGroupId}
|
|
421
|
+
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
|
422
|
+
setSelectedGroupId(e.target.value)
|
|
423
|
+
}
|
|
424
|
+
>
|
|
425
|
+
{groups.map((g) => (
|
|
426
|
+
<option key={g.id} value={g.id}>
|
|
427
|
+
{g.name}
|
|
428
|
+
</option>
|
|
429
|
+
))}
|
|
430
|
+
</select>
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
<div className="space-y-2">
|
|
434
|
+
<Label>Select System</Label>
|
|
435
|
+
<select
|
|
436
|
+
className="w-full flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
437
|
+
value={selectedSystemToAdd}
|
|
438
|
+
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
|
439
|
+
setSelectedSystemToAdd(e.target.value)
|
|
440
|
+
}
|
|
441
|
+
>
|
|
442
|
+
<option value="">Select a system</option>
|
|
443
|
+
{availableSystems.map((s) => (
|
|
444
|
+
<option key={s.id} value={s.id}>
|
|
445
|
+
{s.name}
|
|
446
|
+
</option>
|
|
447
|
+
))}
|
|
448
|
+
</select>
|
|
449
|
+
</div>
|
|
450
|
+
|
|
451
|
+
<div className="flex items-end">
|
|
452
|
+
<Button
|
|
453
|
+
onClick={handleAddSystemToGroup}
|
|
454
|
+
disabled={!selectedSystemToAdd}
|
|
455
|
+
className="w-full"
|
|
456
|
+
>
|
|
457
|
+
<Plus className="w-4 h-4 mr-2" />
|
|
458
|
+
Add to Group
|
|
459
|
+
</Button>
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
</CardContent>
|
|
463
|
+
</Card>
|
|
464
|
+
)}
|
|
465
|
+
|
|
466
|
+
{/* Dialogs */}
|
|
467
|
+
<SystemEditor
|
|
468
|
+
open={isSystemEditorOpen}
|
|
469
|
+
onClose={() => setIsSystemEditorOpen(false)}
|
|
470
|
+
onSave={handleCreateSystem}
|
|
471
|
+
/>
|
|
472
|
+
|
|
473
|
+
<GroupEditor
|
|
474
|
+
open={isGroupEditorOpen}
|
|
475
|
+
onClose={() => setIsGroupEditorOpen(false)}
|
|
476
|
+
onSave={handleCreateGroup}
|
|
477
|
+
/>
|
|
478
|
+
|
|
479
|
+
<ConfirmationModal
|
|
480
|
+
isOpen={confirmModal.isOpen}
|
|
481
|
+
onClose={() => setConfirmModal({ ...confirmModal, isOpen: false })}
|
|
482
|
+
onConfirm={confirmModal.onConfirm}
|
|
483
|
+
title={confirmModal.title}
|
|
484
|
+
message={confirmModal.message}
|
|
485
|
+
confirmText="Delete"
|
|
486
|
+
variant="danger"
|
|
487
|
+
/>
|
|
488
|
+
</div>
|
|
489
|
+
);
|
|
490
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useApi, loggerApiRef } from "@checkstack/frontend-api";
|
|
3
|
+
import { catalogApiRef } from "../api";
|
|
4
|
+
|
|
5
|
+
export const CatalogPage = () => {
|
|
6
|
+
const logger = useApi(loggerApiRef);
|
|
7
|
+
const catalog = useApi(catalogApiRef);
|
|
8
|
+
|
|
9
|
+
React.useEffect(() => {
|
|
10
|
+
logger.info("Catalog Page loaded", catalog);
|
|
11
|
+
}, [logger, catalog]);
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="p-4 rounded-lg bg-white shadow">
|
|
15
|
+
<h2 className="text-2xl font-semibold mb-4">Catalog</h2>
|
|
16
|
+
<p className="text-muted-foreground">Welcome to the Service Catalog.</p>
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Input,
|
|
5
|
+
Label,
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
DialogFooter,
|
|
11
|
+
useToast,
|
|
12
|
+
} from "@checkstack/ui";
|
|
13
|
+
|
|
14
|
+
interface GroupEditorProps {
|
|
15
|
+
open: boolean;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
onSave: (data: { name: string }) => Promise<void>;
|
|
18
|
+
initialData?: { name: string };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const GroupEditor: React.FC<GroupEditorProps> = ({
|
|
22
|
+
open,
|
|
23
|
+
onClose,
|
|
24
|
+
onSave,
|
|
25
|
+
initialData,
|
|
26
|
+
}) => {
|
|
27
|
+
const [name, setName] = useState(initialData?.name || "");
|
|
28
|
+
const [loading, setLoading] = useState(false);
|
|
29
|
+
const toast = useToast();
|
|
30
|
+
|
|
31
|
+
// Reset form when dialog opens
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (open) {
|
|
34
|
+
setName(initialData?.name || "");
|
|
35
|
+
}
|
|
36
|
+
}, [open, initialData]);
|
|
37
|
+
|
|
38
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
if (!name.trim()) return;
|
|
41
|
+
|
|
42
|
+
setLoading(true);
|
|
43
|
+
try {
|
|
44
|
+
await onSave({ name: name.trim() });
|
|
45
|
+
onClose();
|
|
46
|
+
} catch (error) {
|
|
47
|
+
const message =
|
|
48
|
+
error instanceof Error ? error.message : "Failed to save group";
|
|
49
|
+
toast.error(message);
|
|
50
|
+
} finally {
|
|
51
|
+
setLoading(false);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
|
57
|
+
<DialogContent size="default">
|
|
58
|
+
<form onSubmit={handleSubmit}>
|
|
59
|
+
<DialogHeader>
|
|
60
|
+
<DialogTitle>
|
|
61
|
+
{initialData ? "Edit Group" : "Create Group"}
|
|
62
|
+
</DialogTitle>
|
|
63
|
+
</DialogHeader>
|
|
64
|
+
|
|
65
|
+
<div className="space-y-4 py-4">
|
|
66
|
+
<div className="space-y-2">
|
|
67
|
+
<Label htmlFor="group-name">Name</Label>
|
|
68
|
+
<Input
|
|
69
|
+
id="group-name"
|
|
70
|
+
placeholder="e.g. Payment Flow"
|
|
71
|
+
value={name}
|
|
72
|
+
onChange={(e) => setName(e.target.value)}
|
|
73
|
+
required
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<DialogFooter>
|
|
79
|
+
<Button type="button" variant="outline" onClick={onClose}>
|
|
80
|
+
Cancel
|
|
81
|
+
</Button>
|
|
82
|
+
<Button type="submit" disabled={loading || !name.trim()}>
|
|
83
|
+
{loading
|
|
84
|
+
? "Saving..."
|
|
85
|
+
: initialData
|
|
86
|
+
? "Save Changes"
|
|
87
|
+
: "Create Group"}
|
|
88
|
+
</Button>
|
|
89
|
+
</DialogFooter>
|
|
90
|
+
</form>
|
|
91
|
+
</DialogContent>
|
|
92
|
+
</Dialog>
|
|
93
|
+
);
|
|
94
|
+
};
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import React, { useEffect, useState, useCallback } from "react";
|
|
2
|
+
import { useParams, useNavigate } from "react-router-dom";
|
|
3
|
+
import { useApi, rpcApiRef } from "@checkstack/frontend-api";
|
|
4
|
+
import { catalogApiRef, System, Group } from "../api";
|
|
5
|
+
import { ExtensionSlot } from "@checkstack/frontend-api";
|
|
6
|
+
import {
|
|
7
|
+
SystemDetailsSlot,
|
|
8
|
+
SystemDetailsTopSlot,
|
|
9
|
+
SystemStateBadgesSlot,
|
|
10
|
+
} from "@checkstack/catalog-common";
|
|
11
|
+
import { NotificationApi } from "@checkstack/notification-common";
|
|
12
|
+
import {
|
|
13
|
+
Card,
|
|
14
|
+
CardHeader,
|
|
15
|
+
CardTitle,
|
|
16
|
+
CardContent,
|
|
17
|
+
LoadingSpinner,
|
|
18
|
+
SubscribeButton,
|
|
19
|
+
useToast,
|
|
20
|
+
BackLink,
|
|
21
|
+
} from "@checkstack/ui";
|
|
22
|
+
import { authApiRef } from "@checkstack/auth-frontend/api";
|
|
23
|
+
|
|
24
|
+
import { Activity, Info, Users, FileJson, Calendar } from "lucide-react";
|
|
25
|
+
|
|
26
|
+
const CATALOG_PLUGIN_ID = "catalog";
|
|
27
|
+
|
|
28
|
+
export const SystemDetailPage: React.FC = () => {
|
|
29
|
+
const { systemId } = useParams<{ systemId: string }>();
|
|
30
|
+
const navigate = useNavigate();
|
|
31
|
+
const catalogApi = useApi(catalogApiRef);
|
|
32
|
+
const rpcApi = useApi(rpcApiRef);
|
|
33
|
+
const notificationApi = rpcApi.forPlugin(NotificationApi);
|
|
34
|
+
const toast = useToast();
|
|
35
|
+
const authApi = useApi(authApiRef);
|
|
36
|
+
const { data: session } = authApi.useSession();
|
|
37
|
+
|
|
38
|
+
const [system, setSystem] = useState<System | undefined>();
|
|
39
|
+
const [groups, setGroups] = useState<Group[]>([]);
|
|
40
|
+
const [loading, setLoading] = useState(true);
|
|
41
|
+
const [notFound, setNotFound] = useState(false);
|
|
42
|
+
|
|
43
|
+
// Subscription state
|
|
44
|
+
const [isSubscribed, setIsSubscribed] = useState(false);
|
|
45
|
+
const [subscriptionLoading, setSubscriptionLoading] = useState(true);
|
|
46
|
+
|
|
47
|
+
// Construct the full group ID for this system
|
|
48
|
+
const getSystemGroupId = useCallback(() => {
|
|
49
|
+
return `${CATALOG_PLUGIN_ID}.system.${systemId}`;
|
|
50
|
+
}, [systemId]);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!systemId) {
|
|
54
|
+
setNotFound(true);
|
|
55
|
+
setLoading(false);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
Promise.all([catalogApi.getSystems(), catalogApi.getGroups()])
|
|
60
|
+
.then(([systems, allGroups]) => {
|
|
61
|
+
const foundSystem = systems.find((s) => s.id === systemId);
|
|
62
|
+
|
|
63
|
+
if (!foundSystem) {
|
|
64
|
+
setNotFound(true);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
setSystem(foundSystem);
|
|
69
|
+
|
|
70
|
+
// Find groups that contain this system
|
|
71
|
+
const systemGroups = allGroups.filter((group) =>
|
|
72
|
+
group.systemIds?.includes(systemId)
|
|
73
|
+
);
|
|
74
|
+
setGroups(systemGroups);
|
|
75
|
+
})
|
|
76
|
+
.catch((error) => {
|
|
77
|
+
console.error("Error fetching system details:", error);
|
|
78
|
+
setNotFound(true);
|
|
79
|
+
})
|
|
80
|
+
.finally(() => setLoading(false));
|
|
81
|
+
}, [systemId, catalogApi]);
|
|
82
|
+
|
|
83
|
+
// Check subscription status
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!systemId) return;
|
|
86
|
+
|
|
87
|
+
setSubscriptionLoading(true);
|
|
88
|
+
notificationApi
|
|
89
|
+
.getSubscriptions()
|
|
90
|
+
.then((subscriptions) => {
|
|
91
|
+
const groupId = getSystemGroupId();
|
|
92
|
+
const hasSubscription = subscriptions.some(
|
|
93
|
+
(s) => s.groupId === groupId
|
|
94
|
+
);
|
|
95
|
+
setIsSubscribed(hasSubscription);
|
|
96
|
+
})
|
|
97
|
+
.catch((error) => {
|
|
98
|
+
console.error("Failed to check subscription status:", error);
|
|
99
|
+
})
|
|
100
|
+
.finally(() => setSubscriptionLoading(false));
|
|
101
|
+
}, [systemId, notificationApi, getSystemGroupId]);
|
|
102
|
+
|
|
103
|
+
const handleSubscribe = async () => {
|
|
104
|
+
setSubscriptionLoading(true);
|
|
105
|
+
try {
|
|
106
|
+
await notificationApi.subscribe({ groupId: getSystemGroupId() });
|
|
107
|
+
setIsSubscribed(true);
|
|
108
|
+
toast.success("Subscribed to system notifications");
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const message =
|
|
111
|
+
error instanceof Error ? error.message : "Failed to subscribe";
|
|
112
|
+
toast.error(message);
|
|
113
|
+
} finally {
|
|
114
|
+
setSubscriptionLoading(false);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const handleUnsubscribe = async () => {
|
|
119
|
+
setSubscriptionLoading(true);
|
|
120
|
+
try {
|
|
121
|
+
await notificationApi.unsubscribe({ groupId: getSystemGroupId() });
|
|
122
|
+
setIsSubscribed(false);
|
|
123
|
+
toast.success("Unsubscribed from system notifications");
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const message =
|
|
126
|
+
error instanceof Error ? error.message : "Failed to unsubscribe";
|
|
127
|
+
toast.error(message);
|
|
128
|
+
} finally {
|
|
129
|
+
setSubscriptionLoading(false);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
if (loading) {
|
|
134
|
+
return (
|
|
135
|
+
<div className="flex items-center justify-center min-h-[400px]">
|
|
136
|
+
<LoadingSpinner />
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (notFound || !system) {
|
|
142
|
+
return (
|
|
143
|
+
<div className="space-y-6">
|
|
144
|
+
<div className="flex items-center justify-between">
|
|
145
|
+
<div className="flex items-center gap-3">
|
|
146
|
+
<Activity className="h-8 w-8 text-primary" />
|
|
147
|
+
<h1 className="text-3xl font-bold text-foreground">
|
|
148
|
+
System Not Found
|
|
149
|
+
</h1>
|
|
150
|
+
</div>
|
|
151
|
+
<BackLink onClick={() => navigate("/")}>Back to Dashboard</BackLink>
|
|
152
|
+
</div>
|
|
153
|
+
<Card className="border-destructive/30 bg-destructive/10">
|
|
154
|
+
<CardContent className="p-12 text-center">
|
|
155
|
+
<p className="text-destructive">
|
|
156
|
+
The system you're looking for doesn't exist or has been removed.
|
|
157
|
+
</p>
|
|
158
|
+
</CardContent>
|
|
159
|
+
</Card>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<div className="space-y-6 animate-in fade-in duration-500">
|
|
166
|
+
{/* System Name with Subscribe Button and Back Link */}
|
|
167
|
+
<div className="flex items-center justify-between">
|
|
168
|
+
<div className="flex items-center gap-3">
|
|
169
|
+
<Activity className="h-8 w-8 text-primary" />
|
|
170
|
+
<h1 className="text-3xl font-bold text-foreground">{system.name}</h1>
|
|
171
|
+
</div>
|
|
172
|
+
<div className="flex items-center gap-4">
|
|
173
|
+
{session && (
|
|
174
|
+
<SubscribeButton
|
|
175
|
+
isSubscribed={isSubscribed}
|
|
176
|
+
onSubscribe={handleSubscribe}
|
|
177
|
+
onUnsubscribe={handleUnsubscribe}
|
|
178
|
+
loading={subscriptionLoading}
|
|
179
|
+
/>
|
|
180
|
+
)}
|
|
181
|
+
<BackLink onClick={() => navigate("/")}>Back to Dashboard</BackLink>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{/* Top Extension Slot for urgent items like maintenance alerts */}
|
|
186
|
+
<ExtensionSlot slot={SystemDetailsTopSlot} context={{ system }} />
|
|
187
|
+
|
|
188
|
+
{/* System Status Card - displays plugin-provided state badges */}
|
|
189
|
+
<Card className="border-border shadow-sm">
|
|
190
|
+
<CardHeader className="border-b border-border bg-muted/30">
|
|
191
|
+
<div className="flex items-center gap-2">
|
|
192
|
+
<Activity className="h-5 w-5 text-muted-foreground" />
|
|
193
|
+
<CardTitle className="text-lg font-semibold">
|
|
194
|
+
System Status
|
|
195
|
+
</CardTitle>
|
|
196
|
+
</div>
|
|
197
|
+
</CardHeader>
|
|
198
|
+
<CardContent className="p-6">
|
|
199
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
200
|
+
<ExtensionSlot slot={SystemStateBadgesSlot} context={{ system }} />
|
|
201
|
+
</div>
|
|
202
|
+
</CardContent>
|
|
203
|
+
</Card>
|
|
204
|
+
|
|
205
|
+
{/* System Information Card */}
|
|
206
|
+
<Card className="border-border shadow-sm">
|
|
207
|
+
<CardHeader className="border-b border-border bg-muted/30">
|
|
208
|
+
<div className="flex items-center gap-2">
|
|
209
|
+
<Info className="h-5 w-5 text-muted-foreground" />
|
|
210
|
+
<CardTitle className="text-lg font-semibold">
|
|
211
|
+
System Information
|
|
212
|
+
</CardTitle>
|
|
213
|
+
</div>
|
|
214
|
+
</CardHeader>
|
|
215
|
+
<CardContent className="p-6 space-y-4">
|
|
216
|
+
<div>
|
|
217
|
+
<label className="text-sm font-medium text-muted-foreground">
|
|
218
|
+
Description
|
|
219
|
+
</label>
|
|
220
|
+
<p className="mt-1 text-foreground">
|
|
221
|
+
{system.description || "No description provided"}
|
|
222
|
+
</p>
|
|
223
|
+
</div>
|
|
224
|
+
<div>
|
|
225
|
+
<label className="text-sm font-medium text-muted-foreground">
|
|
226
|
+
Owner
|
|
227
|
+
</label>
|
|
228
|
+
<p className="mt-1 text-foreground">
|
|
229
|
+
{system.owner || "Not assigned"}
|
|
230
|
+
</p>
|
|
231
|
+
</div>
|
|
232
|
+
<div className="flex gap-6 text-sm">
|
|
233
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
234
|
+
<Calendar className="h-4 w-4" />
|
|
235
|
+
<span>
|
|
236
|
+
Created:{" "}
|
|
237
|
+
{new Date(system.createdAt).toLocaleDateString("en-US", {
|
|
238
|
+
year: "numeric",
|
|
239
|
+
month: "short",
|
|
240
|
+
day: "numeric",
|
|
241
|
+
})}
|
|
242
|
+
</span>
|
|
243
|
+
</div>
|
|
244
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
245
|
+
<Calendar className="h-4 w-4" />
|
|
246
|
+
<span>
|
|
247
|
+
Updated:{" "}
|
|
248
|
+
{new Date(system.updatedAt).toLocaleDateString("en-US", {
|
|
249
|
+
year: "numeric",
|
|
250
|
+
month: "short",
|
|
251
|
+
day: "numeric",
|
|
252
|
+
})}
|
|
253
|
+
</span>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
</CardContent>
|
|
257
|
+
</Card>
|
|
258
|
+
|
|
259
|
+
{/* Groups Card */}
|
|
260
|
+
<Card className="border-border shadow-sm">
|
|
261
|
+
<CardHeader className="border-b border-border bg-muted/30">
|
|
262
|
+
<div className="flex items-center gap-2">
|
|
263
|
+
<Users className="h-5 w-5 text-muted-foreground" />
|
|
264
|
+
<CardTitle className="text-lg font-semibold">
|
|
265
|
+
Member of Groups
|
|
266
|
+
</CardTitle>
|
|
267
|
+
</div>
|
|
268
|
+
</CardHeader>
|
|
269
|
+
<CardContent className="p-6">
|
|
270
|
+
{groups.length === 0 ? (
|
|
271
|
+
<p className="text-muted-foreground text-sm">
|
|
272
|
+
This system is not part of any groups
|
|
273
|
+
</p>
|
|
274
|
+
) : (
|
|
275
|
+
<div className="flex flex-wrap gap-2">
|
|
276
|
+
{groups.map((group) => (
|
|
277
|
+
<span
|
|
278
|
+
key={group.id}
|
|
279
|
+
className="inline-flex items-center gap-1.5 rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-sm font-medium text-primary"
|
|
280
|
+
>
|
|
281
|
+
{group.name}
|
|
282
|
+
</span>
|
|
283
|
+
))}
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
</CardContent>
|
|
287
|
+
</Card>
|
|
288
|
+
|
|
289
|
+
{/* Metadata Card */}
|
|
290
|
+
{system.metadata &&
|
|
291
|
+
typeof system.metadata === "object" &&
|
|
292
|
+
Object.keys(system.metadata).length > 0 && (
|
|
293
|
+
<Card className="border-border shadow-sm">
|
|
294
|
+
<CardHeader className="border-b border-border bg-muted/30">
|
|
295
|
+
<div className="flex items-center gap-2">
|
|
296
|
+
<FileJson className="h-5 w-5 text-muted-foreground" />
|
|
297
|
+
<CardTitle className="text-lg font-semibold">
|
|
298
|
+
Metadata
|
|
299
|
+
</CardTitle>
|
|
300
|
+
</div>
|
|
301
|
+
</CardHeader>
|
|
302
|
+
<CardContent className="p-6">
|
|
303
|
+
<pre className="text-sm text-foreground bg-muted/30 p-4 rounded border border-border overflow-x-auto">
|
|
304
|
+
{JSON.stringify(system.metadata, undefined, 2)}
|
|
305
|
+
</pre>
|
|
306
|
+
</CardContent>
|
|
307
|
+
</Card>
|
|
308
|
+
)}
|
|
309
|
+
|
|
310
|
+
{/* Extension Slot for System Details */}
|
|
311
|
+
<ExtensionSlot slot={SystemDetailsSlot} context={{ system }} />
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Input,
|
|
5
|
+
Label,
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
DialogFooter,
|
|
11
|
+
useToast,
|
|
12
|
+
} from "@checkstack/ui";
|
|
13
|
+
|
|
14
|
+
interface SystemEditorProps {
|
|
15
|
+
open: boolean;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
onSave: (data: { name: string; description?: string }) => Promise<void>;
|
|
18
|
+
initialData?: { name: string; description?: string };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const SystemEditor: React.FC<SystemEditorProps> = ({
|
|
22
|
+
open,
|
|
23
|
+
onClose,
|
|
24
|
+
onSave,
|
|
25
|
+
initialData,
|
|
26
|
+
}) => {
|
|
27
|
+
const [name, setName] = useState(initialData?.name || "");
|
|
28
|
+
const [description, setDescription] = useState(
|
|
29
|
+
initialData?.description || ""
|
|
30
|
+
);
|
|
31
|
+
const [loading, setLoading] = useState(false);
|
|
32
|
+
const toast = useToast();
|
|
33
|
+
|
|
34
|
+
// Reset form when dialog opens
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (open) {
|
|
37
|
+
setName(initialData?.name || "");
|
|
38
|
+
setDescription(initialData?.description || "");
|
|
39
|
+
}
|
|
40
|
+
}, [open, initialData]);
|
|
41
|
+
|
|
42
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
if (!name.trim()) return;
|
|
45
|
+
|
|
46
|
+
setLoading(true);
|
|
47
|
+
try {
|
|
48
|
+
await onSave({
|
|
49
|
+
name: name.trim(),
|
|
50
|
+
description: description.trim() || undefined,
|
|
51
|
+
});
|
|
52
|
+
onClose();
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const message =
|
|
55
|
+
error instanceof Error ? error.message : "Failed to save system";
|
|
56
|
+
toast.error(message);
|
|
57
|
+
} finally {
|
|
58
|
+
setLoading(false);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
|
64
|
+
<DialogContent size="default">
|
|
65
|
+
<form onSubmit={handleSubmit}>
|
|
66
|
+
<DialogHeader>
|
|
67
|
+
<DialogTitle>
|
|
68
|
+
{initialData ? "Edit System" : "Create System"}
|
|
69
|
+
</DialogTitle>
|
|
70
|
+
</DialogHeader>
|
|
71
|
+
|
|
72
|
+
<div className="space-y-4 py-4">
|
|
73
|
+
<div className="space-y-2">
|
|
74
|
+
<Label htmlFor="system-name">Name</Label>
|
|
75
|
+
<Input
|
|
76
|
+
id="system-name"
|
|
77
|
+
placeholder="e.g. Payments API"
|
|
78
|
+
value={name}
|
|
79
|
+
onChange={(e) => setName(e.target.value)}
|
|
80
|
+
required
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div className="space-y-2">
|
|
85
|
+
<Label htmlFor="system-description">Description (optional)</Label>
|
|
86
|
+
<textarea
|
|
87
|
+
id="system-description"
|
|
88
|
+
className="w-full flex min-h-[80px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none"
|
|
89
|
+
placeholder="Describe what this system does..."
|
|
90
|
+
value={description}
|
|
91
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
92
|
+
rows={3}
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<DialogFooter>
|
|
98
|
+
<Button type="button" variant="outline" onClick={onClose}>
|
|
99
|
+
Cancel
|
|
100
|
+
</Button>
|
|
101
|
+
<Button type="submit" disabled={loading || !name.trim()}>
|
|
102
|
+
{loading
|
|
103
|
+
? "Saving..."
|
|
104
|
+
: initialData
|
|
105
|
+
? "Save Changes"
|
|
106
|
+
: "Create System"}
|
|
107
|
+
</Button>
|
|
108
|
+
</DialogFooter>
|
|
109
|
+
</form>
|
|
110
|
+
</DialogContent>
|
|
111
|
+
</Dialog>
|
|
112
|
+
);
|
|
113
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link } from "react-router-dom";
|
|
3
|
+
import { Settings } from "lucide-react";
|
|
4
|
+
import type { UserMenuItemsContext } from "@checkstack/frontend-api";
|
|
5
|
+
import { DropdownMenuItem } from "@checkstack/ui";
|
|
6
|
+
import { qualifyPermissionId, resolveRoute } from "@checkstack/common";
|
|
7
|
+
import {
|
|
8
|
+
catalogRoutes,
|
|
9
|
+
permissions,
|
|
10
|
+
pluginMetadata,
|
|
11
|
+
} from "@checkstack/catalog-common";
|
|
12
|
+
|
|
13
|
+
export const CatalogUserMenuItems = ({
|
|
14
|
+
permissions: userPerms,
|
|
15
|
+
}: UserMenuItemsContext) => {
|
|
16
|
+
const qualifiedId = qualifyPermissionId(
|
|
17
|
+
pluginMetadata,
|
|
18
|
+
permissions.catalogManage
|
|
19
|
+
);
|
|
20
|
+
const canManage = userPerms.includes("*") || userPerms.includes(qualifiedId);
|
|
21
|
+
|
|
22
|
+
if (!canManage) {
|
|
23
|
+
return <React.Fragment />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Link to={resolveRoute(catalogRoutes.routes.config)}>
|
|
28
|
+
<DropdownMenuItem icon={<Settings className="h-4 w-4" />}>
|
|
29
|
+
Catalog Settings
|
|
30
|
+
</DropdownMenuItem>
|
|
31
|
+
</Link>
|
|
32
|
+
);
|
|
33
|
+
};
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
rpcApiRef,
|
|
3
|
+
ApiRef,
|
|
4
|
+
UserMenuItemsSlot,
|
|
5
|
+
createSlotExtension,
|
|
6
|
+
createFrontendPlugin,
|
|
7
|
+
} from "@checkstack/frontend-api";
|
|
8
|
+
import { catalogApiRef, type CatalogApiClient } from "./api";
|
|
9
|
+
import {
|
|
10
|
+
catalogRoutes,
|
|
11
|
+
CatalogApi,
|
|
12
|
+
pluginMetadata,
|
|
13
|
+
permissions,
|
|
14
|
+
} from "@checkstack/catalog-common";
|
|
15
|
+
|
|
16
|
+
import { CatalogPage } from "./components/CatalogPage";
|
|
17
|
+
import { CatalogConfigPage } from "./components/CatalogConfigPage";
|
|
18
|
+
import { CatalogUserMenuItems } from "./components/UserMenuItems";
|
|
19
|
+
import { SystemDetailPage } from "./components/SystemDetailPage";
|
|
20
|
+
|
|
21
|
+
export const catalogPlugin = createFrontendPlugin({
|
|
22
|
+
metadata: pluginMetadata,
|
|
23
|
+
apis: [
|
|
24
|
+
{
|
|
25
|
+
ref: catalogApiRef,
|
|
26
|
+
factory: (deps: { get: <T>(ref: ApiRef<T>) => T }): CatalogApiClient => {
|
|
27
|
+
const rpcApi = deps.get(rpcApiRef);
|
|
28
|
+
// CatalogApiClient is derived from the contract type
|
|
29
|
+
return rpcApi.forPlugin(CatalogApi);
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
routes: [
|
|
34
|
+
{
|
|
35
|
+
route: catalogRoutes.routes.home,
|
|
36
|
+
element: <CatalogPage />,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
route: catalogRoutes.routes.config,
|
|
40
|
+
element: <CatalogConfigPage />,
|
|
41
|
+
permission: permissions.catalogManage,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
route: catalogRoutes.routes.systemDetail,
|
|
45
|
+
element: <SystemDetailPage />,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
extensions: [
|
|
49
|
+
createSlotExtension(UserMenuItemsSlot, {
|
|
50
|
+
id: "catalog.user-menu.items",
|
|
51
|
+
component: CatalogUserMenuItems,
|
|
52
|
+
}),
|
|
53
|
+
],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export * from "./api";
|