@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 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";
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/frontend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }