@checkmate-monitor/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,18 @@
1
+ # @checkmate-monitor/catalog-frontend
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [eff5b4e]
8
+ - Updated dependencies [ffc28f6]
9
+ - Updated dependencies [4dd644d]
10
+ - Updated dependencies [32f2535]
11
+ - Updated dependencies [b55fae6]
12
+ - Updated dependencies [b354ab3]
13
+ - @checkmate-monitor/ui@0.1.0
14
+ - @checkmate-monitor/common@0.1.0
15
+ - @checkmate-monitor/catalog-common@0.1.0
16
+ - @checkmate-monitor/notification-common@0.1.0
17
+ - @checkmate-monitor/auth-frontend@0.1.0
18
+ - @checkmate-monitor/frontend-api@0.0.2
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@checkmate-monitor/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
+ "@checkmate-monitor/catalog-common": "workspace:*",
13
+ "@checkmate-monitor/frontend-api": "workspace:*",
14
+ "@checkmate-monitor/auth-frontend": "workspace:*",
15
+ "@checkmate-monitor/common": "workspace:*",
16
+ "@checkmate-monitor/notification-common": "workspace:*",
17
+ "@checkmate-monitor/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
+ "@checkmate-monitor/tsconfig": "workspace:*",
26
+ "@checkmate-monitor/scripts": "workspace:*"
27
+ }
28
+ }
package/src/api.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { createApiRef } from "@checkmate-monitor/frontend-api";
2
+ import { CatalogApi } from "@checkmate-monitor/catalog-common";
3
+ import type { InferClient } from "@checkmate-monitor/common";
4
+
5
+ // Re-export types for convenience
6
+ export type { System, Group, View } from "@checkmate-monitor/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,478 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import {
3
+ useApi,
4
+ permissionApiRef,
5
+ ExtensionSlot,
6
+ } from "@checkmate-monitor/frontend-api";
7
+ import { catalogApiRef, System, Group } from "../api";
8
+ import { CatalogSystemActionsSlot } from "@checkmate-monitor/catalog-common";
9
+ import {
10
+ SectionHeader,
11
+ Card,
12
+ CardHeader,
13
+ CardTitle,
14
+ CardContent,
15
+ Button,
16
+ Label,
17
+ LoadingSpinner,
18
+ EmptyState,
19
+ PermissionDenied,
20
+ EditableText,
21
+ ConfirmationModal,
22
+ useToast,
23
+ } from "@checkmate-monitor/ui";
24
+ import { Plus, Trash2, LayoutGrid, Server, Settings } from "lucide-react";
25
+ import { SystemEditor } from "./SystemEditor";
26
+ import { GroupEditor } from "./GroupEditor";
27
+
28
+ export const CatalogConfigPage = () => {
29
+ const catalogApi = useApi(catalogApiRef);
30
+ const permissionApi = useApi(permissionApiRef);
31
+ const toast = useToast();
32
+ const { allowed: canManage, loading: permissionLoading } =
33
+ permissionApi.useManagePermission("catalog");
34
+
35
+ const [systems, setSystems] = useState<System[]>([]);
36
+ const [groups, setGroups] = useState<Group[]>([]);
37
+ const [loading, setLoading] = useState(true);
38
+
39
+ // Dialog state
40
+ const [isSystemEditorOpen, setIsSystemEditorOpen] = useState(false);
41
+ const [isGroupEditorOpen, setIsGroupEditorOpen] = useState(false);
42
+
43
+ const [selectedGroupId, setSelectedGroupId] = useState("");
44
+ const [selectedSystemToAdd, setSelectedSystemToAdd] = useState("");
45
+
46
+ // Confirmation modal state
47
+ const [confirmModal, setConfirmModal] = useState<{
48
+ isOpen: boolean;
49
+ title: string;
50
+ message: string;
51
+ onConfirm: () => void;
52
+ }>({
53
+ isOpen: false,
54
+ title: "",
55
+ message: "",
56
+ onConfirm: () => {},
57
+ });
58
+
59
+ const loadData = async () => {
60
+ setLoading(true);
61
+ try {
62
+ const [s, g] = await Promise.all([
63
+ catalogApi.getSystems(),
64
+ catalogApi.getGroups(),
65
+ ]);
66
+ setSystems(s);
67
+ setGroups(g);
68
+ if (g.length > 0 && !selectedGroupId) {
69
+ setSelectedGroupId(g[0].id);
70
+ }
71
+ } catch (error) {
72
+ const message =
73
+ error instanceof Error ? error.message : "Failed to load catalog data";
74
+ toast.error(message);
75
+ console.error("Failed to load catalog data:", error);
76
+ } finally {
77
+ setLoading(false);
78
+ }
79
+ };
80
+
81
+ useEffect(() => {
82
+ loadData();
83
+ }, []);
84
+
85
+ const handleCreateSystem = async (data: {
86
+ name: string;
87
+ description?: string;
88
+ }) => {
89
+ await catalogApi.createSystem(data);
90
+ toast.success("System created successfully");
91
+ await loadData();
92
+ };
93
+
94
+ const handleCreateGroup = async (data: { name: string }) => {
95
+ await catalogApi.createGroup(data);
96
+ toast.success("Group created successfully");
97
+ await loadData();
98
+ };
99
+
100
+ const handleDeleteSystem = async (id: string) => {
101
+ const system = systems.find((s) => s.id === id);
102
+ setConfirmModal({
103
+ isOpen: true,
104
+ title: "Delete System",
105
+ message: `Are you sure you want to delete "${system?.name}"? This will remove the system from all groups as well.`,
106
+ onConfirm: async () => {
107
+ try {
108
+ await catalogApi.deleteSystem(id);
109
+ setConfirmModal({ ...confirmModal, isOpen: false });
110
+ toast.success("System deleted successfully");
111
+ loadData();
112
+ } catch (error) {
113
+ const message =
114
+ error instanceof Error ? error.message : "Failed to delete system";
115
+ toast.error(message);
116
+ console.error("Failed to delete system:", error);
117
+ }
118
+ },
119
+ });
120
+ };
121
+
122
+ const handleDeleteGroup = async (id: string) => {
123
+ const group = groups.find((g) => g.id === id);
124
+ setConfirmModal({
125
+ isOpen: true,
126
+ title: "Delete Group",
127
+ message: `Are you sure you want to delete "${group?.name}"? This action cannot be undone.`,
128
+ onConfirm: async () => {
129
+ try {
130
+ await catalogApi.deleteGroup(id);
131
+ setConfirmModal({ ...confirmModal, isOpen: false });
132
+ toast.success("Group deleted successfully");
133
+ loadData();
134
+ } catch (error) {
135
+ const message =
136
+ error instanceof Error ? error.message : "Failed to delete group";
137
+ toast.error(message);
138
+ console.error("Failed to delete group:", error);
139
+ }
140
+ },
141
+ });
142
+ };
143
+
144
+ const handleAddSystemToGroup = async () => {
145
+ if (!selectedGroupId || !selectedSystemToAdd) return;
146
+ try {
147
+ await catalogApi.addSystemToGroup({
148
+ groupId: selectedGroupId,
149
+ systemId: selectedSystemToAdd,
150
+ });
151
+ setSelectedSystemToAdd("");
152
+ toast.success("System added to group successfully");
153
+ loadData();
154
+ } catch (error) {
155
+ const message =
156
+ error instanceof Error
157
+ ? error.message
158
+ : "Failed to add system to group";
159
+ toast.error(message);
160
+ console.error("Failed to add system to group:", error);
161
+ }
162
+ };
163
+
164
+ const handleRemoveSystemFromGroup = async (
165
+ groupId: string,
166
+ systemId: string
167
+ ) => {
168
+ try {
169
+ await catalogApi.removeSystemFromGroup({ groupId, systemId });
170
+ toast.success("System removed from group successfully");
171
+ loadData();
172
+ } catch (error) {
173
+ const message =
174
+ error instanceof Error
175
+ ? error.message
176
+ : "Failed to remove system from group";
177
+ toast.error(message);
178
+ console.error("Failed to remove system from group:", error);
179
+ }
180
+ };
181
+
182
+ const handleUpdateSystemName = async (id: string, newName: string) => {
183
+ try {
184
+ await catalogApi.updateSystem({ id, data: { name: newName } });
185
+ toast.success("System name updated successfully");
186
+ loadData();
187
+ } catch (error) {
188
+ const message =
189
+ error instanceof Error ? error.message : "Failed to update system name";
190
+ toast.error(message);
191
+ console.error("Failed to update system name:", error);
192
+ throw error;
193
+ }
194
+ };
195
+
196
+ const handleUpdateSystemDescription = async (
197
+ id: string,
198
+ newDescription: string
199
+ ) => {
200
+ try {
201
+ await catalogApi.updateSystem({
202
+ id,
203
+ data: { description: newDescription },
204
+ });
205
+ toast.success("System description updated successfully");
206
+ loadData();
207
+ } catch (error) {
208
+ const message =
209
+ error instanceof Error
210
+ ? error.message
211
+ : "Failed to update system description";
212
+ toast.error(message);
213
+ console.error("Failed to update system description:", error);
214
+ throw error;
215
+ }
216
+ };
217
+
218
+ const handleUpdateGroupName = async (id: string, newName: string) => {
219
+ try {
220
+ await catalogApi.updateGroup({ id, data: { name: newName } });
221
+ toast.success("Group name updated successfully");
222
+ loadData();
223
+ } catch (error) {
224
+ const message =
225
+ error instanceof Error ? error.message : "Failed to update group name";
226
+ toast.error(message);
227
+ console.error("Failed to update group name:", error);
228
+ throw error;
229
+ }
230
+ };
231
+
232
+ if (loading || permissionLoading) return <LoadingSpinner />;
233
+
234
+ if (!canManage) {
235
+ return <PermissionDenied />;
236
+ }
237
+
238
+ const selectedGroup = groups.find((g) => g.id === selectedGroupId);
239
+ const availableSystems = systems.filter(
240
+ (s) => !selectedGroup?.systemIds?.includes(s.id)
241
+ );
242
+
243
+ return (
244
+ <div className="space-y-8">
245
+ <SectionHeader
246
+ title="Catalog Management"
247
+ description="Manage systems and logical groups within your infrastructure"
248
+ icon={<Settings className="w-6 h-6 text-primary" />}
249
+ />
250
+
251
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
252
+ {/* Systems Management */}
253
+ <Card>
254
+ <CardHeader className="flex flex-row items-center justify-between">
255
+ <CardTitle className="flex items-center gap-2">
256
+ <Server className="w-5 h-5 text-muted-foreground" />
257
+ Systems
258
+ </CardTitle>
259
+ <Button size="sm" onClick={() => setIsSystemEditorOpen(true)}>
260
+ <Plus className="w-4 h-4 mr-2" />
261
+ Add System
262
+ </Button>
263
+ </CardHeader>
264
+ <CardContent className="space-y-4">
265
+ {systems.length === 0 ? (
266
+ <EmptyState title="No systems created yet." />
267
+ ) : (
268
+ <div className="space-y-2">
269
+ {systems.map((system) => (
270
+ <div
271
+ key={system.id}
272
+ className="flex items-start justify-between p-3 bg-muted/30 rounded-lg border border-border"
273
+ >
274
+ <div className="flex-1 space-y-1">
275
+ <div className="flex items-center justify-between">
276
+ <EditableText
277
+ value={system.name}
278
+ onSave={(newName) =>
279
+ handleUpdateSystemName(system.id, newName)
280
+ }
281
+ className="font-medium text-foreground"
282
+ />
283
+ <ExtensionSlot
284
+ slot={CatalogSystemActionsSlot}
285
+ context={{
286
+ systemId: system.id,
287
+ systemName: system.name,
288
+ }}
289
+ />
290
+ </div>
291
+ <EditableText
292
+ value={system.description || "No description"}
293
+ onSave={(newDescription) =>
294
+ handleUpdateSystemDescription(
295
+ system.id,
296
+ newDescription
297
+ )
298
+ }
299
+ className="text-xs text-muted-foreground font-mono"
300
+ placeholder="Add description..."
301
+ />
302
+ </div>
303
+ <Button
304
+ variant="ghost"
305
+ className="text-destructive hover:text-destructive/90 hover:bg-destructive/10 h-8 w-8 p-0"
306
+ onClick={() => handleDeleteSystem(system.id)}
307
+ >
308
+ <Trash2 className="w-4 h-4" />
309
+ </Button>
310
+ </div>
311
+ ))}
312
+ </div>
313
+ )}
314
+ </CardContent>
315
+ </Card>
316
+
317
+ {/* Groups Management */}
318
+ <Card>
319
+ <CardHeader className="flex flex-row items-center justify-between">
320
+ <CardTitle className="flex items-center gap-2">
321
+ <LayoutGrid className="w-5 h-5 text-muted-foreground" />
322
+ Groups
323
+ </CardTitle>
324
+ <Button size="sm" onClick={() => setIsGroupEditorOpen(true)}>
325
+ <Plus className="w-4 h-4 mr-2" />
326
+ Add Group
327
+ </Button>
328
+ </CardHeader>
329
+ <CardContent className="space-y-4">
330
+ {groups.length === 0 ? (
331
+ <EmptyState title="No groups created yet." />
332
+ ) : (
333
+ <div className="space-y-2">
334
+ {groups.map((group) => (
335
+ <div
336
+ key={group.id}
337
+ className="p-3 bg-muted/30 rounded-lg border border-border space-y-2"
338
+ >
339
+ <div className="flex items-center justify-between">
340
+ <div className="flex-1">
341
+ <EditableText
342
+ value={group.name}
343
+ onSave={(newName) =>
344
+ handleUpdateGroupName(group.id, newName)
345
+ }
346
+ className="font-medium text-foreground"
347
+ />
348
+ <p className="text-xs text-muted-foreground font-mono">
349
+ {group.id}
350
+ </p>
351
+ </div>
352
+ <Button
353
+ variant="ghost"
354
+ className="text-destructive hover:text-destructive/90 hover:bg-destructive/10 h-8 w-8 p-0"
355
+ onClick={() => handleDeleteGroup(group.id)}
356
+ >
357
+ <Trash2 className="w-4 h-4" />
358
+ </Button>
359
+ </div>
360
+
361
+ {/* Systems in this group */}
362
+ {group.systemIds && group.systemIds.length > 0 && (
363
+ <div className="pl-4 space-y-1">
364
+ {group.systemIds
365
+ .map((sysId) => systems.find((s) => s.id === sysId))
366
+ .filter((sys): sys is System => !!sys)
367
+ .map((sys) => (
368
+ <div
369
+ key={sys.id}
370
+ className="flex items-center justify-between text-sm bg-background p-2 rounded border border-border"
371
+ >
372
+ <span className="text-foreground">
373
+ {sys.name}
374
+ </span>
375
+ <Button
376
+ variant="ghost"
377
+ className="text-destructive/60 hover:text-destructive h-6 w-6 p-0"
378
+ onClick={() =>
379
+ handleRemoveSystemFromGroup(group.id, sys.id)
380
+ }
381
+ >
382
+ <Trash2 className="w-3 h-3" />
383
+ </Button>
384
+ </div>
385
+ ))}
386
+ </div>
387
+ )}
388
+ </div>
389
+ ))}
390
+ </div>
391
+ )}
392
+ </CardContent>
393
+ </Card>
394
+ </div>
395
+
396
+ {/* Add System to Group Section */}
397
+ {groups.length > 0 && systems.length > 0 && (
398
+ <Card>
399
+ <CardHeader>
400
+ <CardTitle>Add System to Group</CardTitle>
401
+ </CardHeader>
402
+ <CardContent className="space-y-4">
403
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
404
+ <div className="space-y-2">
405
+ <Label>Select Group</Label>
406
+ <select
407
+ 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"
408
+ value={selectedGroupId}
409
+ onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
410
+ setSelectedGroupId(e.target.value)
411
+ }
412
+ >
413
+ {groups.map((g) => (
414
+ <option key={g.id} value={g.id}>
415
+ {g.name}
416
+ </option>
417
+ ))}
418
+ </select>
419
+ </div>
420
+
421
+ <div className="space-y-2">
422
+ <Label>Select System</Label>
423
+ <select
424
+ 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"
425
+ value={selectedSystemToAdd}
426
+ onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
427
+ setSelectedSystemToAdd(e.target.value)
428
+ }
429
+ >
430
+ <option value="">Select a system</option>
431
+ {availableSystems.map((s) => (
432
+ <option key={s.id} value={s.id}>
433
+ {s.name}
434
+ </option>
435
+ ))}
436
+ </select>
437
+ </div>
438
+
439
+ <div className="flex items-end">
440
+ <Button
441
+ onClick={handleAddSystemToGroup}
442
+ disabled={!selectedSystemToAdd}
443
+ className="w-full"
444
+ >
445
+ <Plus className="w-4 h-4 mr-2" />
446
+ Add to Group
447
+ </Button>
448
+ </div>
449
+ </div>
450
+ </CardContent>
451
+ </Card>
452
+ )}
453
+
454
+ {/* Dialogs */}
455
+ <SystemEditor
456
+ open={isSystemEditorOpen}
457
+ onClose={() => setIsSystemEditorOpen(false)}
458
+ onSave={handleCreateSystem}
459
+ />
460
+
461
+ <GroupEditor
462
+ open={isGroupEditorOpen}
463
+ onClose={() => setIsGroupEditorOpen(false)}
464
+ onSave={handleCreateGroup}
465
+ />
466
+
467
+ <ConfirmationModal
468
+ isOpen={confirmModal.isOpen}
469
+ onClose={() => setConfirmModal({ ...confirmModal, isOpen: false })}
470
+ onConfirm={confirmModal.onConfirm}
471
+ title={confirmModal.title}
472
+ message={confirmModal.message}
473
+ confirmText="Delete"
474
+ variant="danger"
475
+ />
476
+ </div>
477
+ );
478
+ };
@@ -0,0 +1,19 @@
1
+ import React from "react";
2
+ import { useApi, loggerApiRef } from "@checkmate-monitor/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 "@checkmate-monitor/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 "@checkmate-monitor/frontend-api";
4
+ import { catalogApiRef, System, Group } from "../api";
5
+ import { ExtensionSlot } from "@checkmate-monitor/frontend-api";
6
+ import {
7
+ SystemDetailsSlot,
8
+ SystemDetailsTopSlot,
9
+ SystemStateBadgesSlot,
10
+ } from "@checkmate-monitor/catalog-common";
11
+ import { NotificationApi } from "@checkmate-monitor/notification-common";
12
+ import {
13
+ Card,
14
+ CardHeader,
15
+ CardTitle,
16
+ CardContent,
17
+ LoadingSpinner,
18
+ SubscribeButton,
19
+ useToast,
20
+ BackLink,
21
+ } from "@checkmate-monitor/ui";
22
+ import { authApiRef } from "@checkmate-monitor/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-foreground">
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 "@checkmate-monitor/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,25 @@
1
+ import React from "react";
2
+ import { Link } from "react-router-dom";
3
+ import { Settings } from "lucide-react";
4
+ import { useApi, permissionApiRef } from "@checkmate-monitor/frontend-api";
5
+ import { DropdownMenuItem } from "@checkmate-monitor/ui";
6
+ import { resolveRoute } from "@checkmate-monitor/common";
7
+ import { catalogRoutes } from "@checkmate-monitor/catalog-common";
8
+
9
+ export const CatalogUserMenuItems = () => {
10
+ const permissionApi = useApi(permissionApiRef);
11
+ const { allowed: canManage, loading } =
12
+ permissionApi.useManagePermission("catalog");
13
+
14
+ if (loading || !canManage) {
15
+ return <React.Fragment />;
16
+ }
17
+
18
+ return (
19
+ <Link to={resolveRoute(catalogRoutes.routes.config)}>
20
+ <DropdownMenuItem icon={<Settings className="h-4 w-4" />}>
21
+ Catalog Settings
22
+ </DropdownMenuItem>
23
+ </Link>
24
+ );
25
+ };
package/src/index.tsx ADDED
@@ -0,0 +1,56 @@
1
+ import {
2
+ rpcApiRef,
3
+ ApiRef,
4
+ UserMenuItemsSlot,
5
+ } from "@checkmate-monitor/frontend-api";
6
+ import { catalogApiRef, type CatalogApiClient } from "./api";
7
+ import { createFrontendPlugin } from "@checkmate-monitor/frontend-api";
8
+ import {
9
+ catalogRoutes,
10
+ CatalogApi,
11
+ pluginMetadata,
12
+ permissions,
13
+ } from "@checkmate-monitor/catalog-common";
14
+
15
+ import { CatalogPage } from "./components/CatalogPage";
16
+ import { CatalogConfigPage } from "./components/CatalogConfigPage";
17
+ import { CatalogUserMenuItems } from "./components/UserMenuItems";
18
+ import { SystemDetailPage } from "./components/SystemDetailPage";
19
+
20
+ export const catalogPlugin = createFrontendPlugin({
21
+ metadata: pluginMetadata,
22
+ apis: [
23
+ {
24
+ ref: catalogApiRef,
25
+ factory: (deps: { get: <T>(ref: ApiRef<T>) => T }): CatalogApiClient => {
26
+ const rpcApi = deps.get(rpcApiRef);
27
+ // CatalogApiClient is derived from the contract type
28
+ return rpcApi.forPlugin(CatalogApi);
29
+ },
30
+ },
31
+ ],
32
+ routes: [
33
+ {
34
+ route: catalogRoutes.routes.home,
35
+ element: <CatalogPage />,
36
+ },
37
+ {
38
+ route: catalogRoutes.routes.config,
39
+ element: <CatalogConfigPage />,
40
+ permission: permissions.catalogManage,
41
+ },
42
+ {
43
+ route: catalogRoutes.routes.systemDetail,
44
+ element: <SystemDetailPage />,
45
+ },
46
+ ],
47
+ extensions: [
48
+ {
49
+ id: "catalog.user-menu.items",
50
+ slot: UserMenuItemsSlot,
51
+ component: CatalogUserMenuItems,
52
+ },
53
+ ],
54
+ });
55
+
56
+ export * from "./api";
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkmate-monitor/tsconfig/frontend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }