@checkstack/catalog-frontend 0.10.7 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/package.json +10 -9
  3. package/src/api.ts +6 -1
  4. package/src/components/CatalogConfigPage.tsx +337 -271
  5. package/src/components/CatalogPage.tsx +172 -11
  6. package/src/components/EnvironmentEditor.tsx +220 -0
  7. package/src/components/EnvironmentPreviewPicker.tsx +61 -0
  8. package/src/components/SystemDetailPage.tsx +47 -34
  9. package/src/components/SystemEditor.tsx +6 -0
  10. package/src/components/SystemEnvironmentsEditor.tsx +98 -0
  11. package/src/components/browse/CatalogBrowseHealth.tsx +36 -0
  12. package/src/components/browse/CatalogBrowseToolbar.tsx +173 -0
  13. package/src/components/browse/CatalogGroupSection.tsx +165 -0
  14. package/src/components/browse/CatalogSystemRow.tsx +63 -0
  15. package/src/components/browse/browseState.logic.test.ts +125 -0
  16. package/src/components/browse/browseState.logic.ts +158 -0
  17. package/src/components/browse/filterEntities.logic.test.ts +479 -0
  18. package/src/components/browse/filterEntities.logic.ts +360 -0
  19. package/src/components/browse/healthRollup.logic.test.ts +126 -0
  20. package/src/components/browse/healthRollup.logic.ts +120 -0
  21. package/src/components/browse/healthStatuses.logic.test.ts +39 -0
  22. package/src/components/browse/healthStatuses.logic.ts +29 -0
  23. package/src/components/environment-fields.logic.test.ts +111 -0
  24. package/src/components/environment-fields.logic.ts +98 -0
  25. package/src/components/environment-preview.logic.test.ts +76 -0
  26. package/src/components/environment-preview.logic.ts +61 -0
  27. package/src/components/manage/AssignMenu.tsx +78 -0
  28. package/src/components/manage/EnvironmentsTab.tsx +230 -0
  29. package/src/components/manage/GroupsTab.tsx +274 -0
  30. package/src/components/manage/SystemsTab.tsx +430 -0
  31. package/src/hooks/useCatalogBrowseState.ts +107 -0
  32. package/src/hooks/useDebouncedValue.ts +21 -0
  33. package/src/index.tsx +32 -20
  34. package/src/utils/formatDate.logic.test.ts +44 -0
  35. package/src/utils/formatDate.logic.ts +27 -0
  36. package/src/utils/normalizeMetadata.logic.test.ts +67 -0
  37. package/src/utils/normalizeMetadata.logic.ts +53 -0
  38. package/src/components/DraggableSystem.tsx +0 -200
  39. package/src/components/DroppableGroup.tsx +0 -174
  40. package/src/components/UserMenuItems.tsx +0 -31
@@ -1,18 +1,179 @@
1
- import React from "react";
2
- import { useApi, loggerApiRef } from "@checkstack/frontend-api";
3
- import { PageLayout } from "@checkstack/ui";
4
- import { Layers } from "lucide-react";
1
+ import React, { useCallback, useMemo, useState } from "react";
2
+ import {
3
+ usePluginClient,
4
+ useApi,
5
+ accessApiRef,
6
+ wrapInSuspense,
7
+ } from "@checkstack/frontend-api";
8
+ import { resolveRoute } from "@checkstack/common";
9
+ import {
10
+ catalogRoutes,
11
+ catalogAccess,
12
+ CatalogApi,
13
+ type CatalogHealthStatuses,
14
+ } from "@checkstack/catalog-common";
15
+ import {
16
+ PageLayout,
17
+ EmptyState,
18
+ ListEmptyState,
19
+ LoadingSpinner,
20
+ Button,
21
+ } from "@checkstack/ui";
22
+ import { Layers, ArrowRight, Plus } from "lucide-react";
23
+ import { Link } from "react-router-dom";
24
+ import { useCatalogBrowseState } from "../hooks/useCatalogBrowseState";
25
+ import { CatalogBrowseToolbar } from "./browse/CatalogBrowseToolbar";
26
+ import { CatalogGroupSection } from "./browse/CatalogGroupSection";
27
+ import { CatalogBrowseHealth } from "./browse/CatalogBrowseHealth";
28
+ import {
29
+ buildBrowseModel,
30
+ collectTagOptions,
31
+ } from "./browse/filterEntities.logic";
32
+ import { healthStatusesEqual } from "./browse/healthStatuses.logic";
5
33
 
6
- export const CatalogPage = () => {
7
- const logger = useApi(loggerApiRef);
34
+ const CatalogPageContent: React.FC = () => {
35
+ const catalogClient = usePluginClient(CatalogApi);
36
+ const accessApi = useApi(accessApiRef);
37
+ const { allowed: canManage } = accessApi.useAccess(
38
+ catalogAccess.system.manage,
39
+ );
40
+
41
+ const { data, isLoading } = catalogClient.getEntities.useQuery({});
42
+
43
+ const browse = useCatalogBrowseState();
44
+
45
+ const systems = useMemo(() => data?.systems ?? [], [data]);
46
+ const groups = useMemo(() => data?.groups ?? [], [data]);
8
47
 
9
- React.useEffect(() => {
10
- logger.info("Catalog Page loaded");
11
- }, [logger]);
48
+ const tagOptions = useMemo(() => collectTagOptions(systems), [systems]);
49
+
50
+ // Bulk health reported by the optional CatalogBrowseHealthSlot filler. `null`
51
+ // until a filler reports (or forever, if no health source is installed). The
52
+ // group rollups + health filter derive from this DATA, never from rendered
53
+ // badges (healthy systems emit no badge).
54
+ const [healthStatuses, setHealthStatuses] =
55
+ useState<CatalogHealthStatuses | null>(null);
56
+ // The filler re-reports a new statuses object on every render/poll; only adopt
57
+ // it when a value actually changed. Returning `prev` unchanged lets React bail
58
+ // out of the re-render, which stops the browse model from recomputing and the
59
+ // system rows from remounting in a loop (so they stay interactive).
60
+ const handleStatuses = useCallback((statuses: CatalogHealthStatuses) => {
61
+ setHealthStatuses((prev) =>
62
+ healthStatusesEqual(prev, statuses) ? prev : statuses,
63
+ );
64
+ }, []);
65
+ const healthEnabled = healthStatuses !== null;
66
+
67
+ const systemIds = useMemo(() => systems.map((s) => s.id), [systems]);
68
+
69
+ const model = useMemo(
70
+ () =>
71
+ buildBrowseModel({
72
+ systems,
73
+ groups,
74
+ // Filter on the debounced query so typing stays smooth on large lists.
75
+ state: { ...browse.state, query: browse.debouncedQuery },
76
+ statuses: healthStatuses ?? undefined,
77
+ }),
78
+ [systems, groups, browse.state, browse.debouncedQuery, healthStatuses],
79
+ );
80
+
81
+ const manageLink = (
82
+ <Link
83
+ to={resolveRoute(catalogRoutes.routes.config)}
84
+ className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
85
+ >
86
+ Manage catalog
87
+ <ArrowRight className="w-4 h-4" />
88
+ </Link>
89
+ );
12
90
 
13
91
  return (
14
- <PageLayout title="Catalog" icon={Layers}>
15
- <p className="text-muted-foreground">Welcome to the Service Catalog.</p>
92
+ <PageLayout
93
+ title="Catalog"
94
+ subtitle="Browse every system, grouped the way your teams think about them"
95
+ icon={Layers}
96
+ actions={canManage ? manageLink : undefined}
97
+ >
98
+ {isLoading ? (
99
+ <div className="p-12 flex justify-center">
100
+ <LoadingSpinner />
101
+ </div>
102
+ ) : model.isEmptyCatalog ? (
103
+ <EmptyState
104
+ icon={<Layers className="size-10" />}
105
+ title="No systems in the catalog yet"
106
+ description="Systems are the things Checkstack keeps an eye on — services, hosts, jobs, databases. Almost everything else (health checks, SLOs, incidents) hangs off a system. Once you add one, it shows up here."
107
+ steps={[
108
+ "Open catalog management to register your first system.",
109
+ "Group related systems by team, product area, or environment.",
110
+ "Come back here to browse and search the whole catalog at a glance.",
111
+ ]}
112
+ actions={
113
+ canManage ? (
114
+ <Button asChild>
115
+ <Link to={resolveRoute(catalogRoutes.routes.config)}>
116
+ <Plus className="w-4 h-4 mr-2" />
117
+ Add your first system
118
+ </Link>
119
+ </Button>
120
+ ) : undefined
121
+ }
122
+ />
123
+ ) : (
124
+ <div className="space-y-6">
125
+ {/*
126
+ Headless health boundary: the optional CatalogBrowseHealthSlot filler
127
+ bulk-fetches system health and reports it via onStatuses. Renders
128
+ nothing when no health source is installed (slot unfilled).
129
+ */}
130
+ <CatalogBrowseHealth
131
+ systemIds={systemIds}
132
+ onStatuses={handleStatuses}
133
+ />
134
+
135
+ <CatalogBrowseToolbar
136
+ query={browse.state.query}
137
+ onQueryChange={browse.setQuery}
138
+ group={browse.state.group}
139
+ onGroupChange={browse.setGroup}
140
+ groups={groups}
141
+ health={browse.state.health}
142
+ onHealthChange={browse.setHealth}
143
+ healthEnabled={healthEnabled}
144
+ tag={browse.state.tag}
145
+ onTagChange={browse.setTag}
146
+ tagOptions={tagOptions}
147
+ density={browse.state.density}
148
+ onDensityChange={browse.setDensity}
149
+ />
150
+
151
+ {model.isFilteredEmpty ? (
152
+ <ListEmptyState
153
+ resource="systems"
154
+ description="No systems match the current search and filters."
155
+ actions={
156
+ <Button variant="outline" onClick={browse.clearFilters}>
157
+ Clear filters
158
+ </Button>
159
+ }
160
+ />
161
+ ) : (
162
+ <div className="space-y-3">
163
+ {model.sections.map((section) => (
164
+ <CatalogGroupSection
165
+ key={section.id}
166
+ section={section}
167
+ density={model.density}
168
+ onToggle={browse.setSectionOpen}
169
+ />
170
+ ))}
171
+ </div>
172
+ )}
173
+ </div>
174
+ )}
16
175
  </PageLayout>
17
176
  );
18
177
  };
178
+
179
+ export const CatalogPage = wrapInSuspense(CatalogPageContent);
@@ -0,0 +1,220 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import {
3
+ Button,
4
+ Input,
5
+ Label,
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ DialogFooter,
12
+ useToast,
13
+ } from "@checkstack/ui";
14
+ import { Plus, Trash2 } from "lucide-react";
15
+ import { extractErrorMessage } from "@checkstack/common";
16
+ import {
17
+ metadataToRows,
18
+ rowsToMetadata,
19
+ hasDuplicateKeys,
20
+ emptyRow,
21
+ type CustomFieldRow,
22
+ } from "./environment-fields.logic";
23
+
24
+ export interface EnvironmentEditorInitialData {
25
+ name: string;
26
+ description?: string;
27
+ metadata?: Record<string, unknown> | null;
28
+ }
29
+
30
+ interface EnvironmentEditorProps {
31
+ open: boolean;
32
+ onClose: () => void;
33
+ onSave: (data: {
34
+ name: string;
35
+ description?: string;
36
+ metadata?: Record<string, string>;
37
+ }) => Promise<void>;
38
+ initialData?: EnvironmentEditorInitialData;
39
+ }
40
+
41
+ /**
42
+ * Create/edit dialog for an instance-wide environment. Mirrors
43
+ * {@link GroupEditor} for name/description and adds a free-form key/value
44
+ * custom-fields editor (v1 metadata is free-form; the pure row<->record
45
+ * conversion lives in `environment-fields.logic.ts`).
46
+ */
47
+ export const EnvironmentEditor: React.FC<EnvironmentEditorProps> = ({
48
+ open,
49
+ onClose,
50
+ onSave,
51
+ initialData,
52
+ }) => {
53
+ const [name, setName] = useState(initialData?.name ?? "");
54
+ const [description, setDescription] = useState(
55
+ initialData?.description ?? "",
56
+ );
57
+ const [fields, setFields] = useState<CustomFieldRow[]>(() =>
58
+ metadataToRows(initialData?.metadata),
59
+ );
60
+ const [loading, setLoading] = useState(false);
61
+ const toast = useToast();
62
+
63
+ useEffect(() => {
64
+ if (open) {
65
+ setName(initialData?.name ?? "");
66
+ setDescription(initialData?.description ?? "");
67
+ setFields(metadataToRows(initialData?.metadata));
68
+ }
69
+ }, [open, initialData]);
70
+
71
+ const updateField = (rowId: string, patch: Partial<CustomFieldRow>) => {
72
+ setFields((prev) =>
73
+ prev.map((row) => (row.rowId === rowId ? { ...row, ...patch } : row)),
74
+ );
75
+ };
76
+
77
+ const removeField = (rowId: string) => {
78
+ setFields((prev) => prev.filter((row) => row.rowId !== rowId));
79
+ };
80
+
81
+ const addField = () => {
82
+ setFields((prev) => [...prev, emptyRow()]);
83
+ };
84
+
85
+ const handleSubmit = async (e: React.FormEvent) => {
86
+ e.preventDefault();
87
+ if (!name.trim()) return;
88
+ if (hasDuplicateKeys(fields)) {
89
+ toast.error("Custom field keys must be unique");
90
+ return;
91
+ }
92
+
93
+ setLoading(true);
94
+ try {
95
+ await onSave({
96
+ name: name.trim(),
97
+ description: description.trim() || undefined,
98
+ metadata: rowsToMetadata(fields),
99
+ });
100
+ onClose();
101
+ } catch (error) {
102
+ toast.error(extractErrorMessage(error, "Failed to save environment"));
103
+ } finally {
104
+ setLoading(false);
105
+ }
106
+ };
107
+
108
+ return (
109
+ <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
110
+ <DialogContent size="default">
111
+ <form onSubmit={handleSubmit}>
112
+ <DialogHeader>
113
+ <DialogTitle>
114
+ {initialData ? "Edit Environment" : "Create Environment"}
115
+ </DialogTitle>
116
+ <DialogDescription className="sr-only">
117
+ {initialData
118
+ ? "Modify this environment and its custom fields"
119
+ : "Create a new environment with custom fields"}
120
+ </DialogDescription>
121
+ </DialogHeader>
122
+
123
+ <div className="space-y-4 py-4">
124
+ <div className="space-y-2">
125
+ <Label htmlFor="environment-name">Name</Label>
126
+ <Input
127
+ id="environment-name"
128
+ placeholder="e.g. Production"
129
+ value={name}
130
+ onChange={(e) => setName(e.target.value)}
131
+ required
132
+ />
133
+ </div>
134
+
135
+ <div className="space-y-2">
136
+ <Label htmlFor="environment-description">
137
+ Description (optional)
138
+ </Label>
139
+ <Input
140
+ id="environment-description"
141
+ placeholder="e.g. Live production traffic"
142
+ value={description}
143
+ onChange={(e) => setDescription(e.target.value)}
144
+ />
145
+ </div>
146
+
147
+ <div className="space-y-2">
148
+ <div className="flex items-center justify-between">
149
+ <Label>Custom fields</Label>
150
+ <Button
151
+ type="button"
152
+ size="sm"
153
+ variant="outline"
154
+ onClick={addField}
155
+ >
156
+ <Plus className="w-4 h-4 mr-1" />
157
+ Add field
158
+ </Button>
159
+ </div>
160
+ <p className="text-xs text-muted-foreground">
161
+ Free-form key/value pairs (baseUrl, region, tier, ...). These
162
+ surface to checks assigned to systems in this environment.
163
+ </p>
164
+ {fields.length === 0 ? (
165
+ <p className="text-sm text-muted-foreground py-2">
166
+ No custom fields yet.
167
+ </p>
168
+ ) : (
169
+ <div className="space-y-2">
170
+ {fields.map((row) => (
171
+ <div key={row.rowId} className="flex items-center gap-2">
172
+ <Input
173
+ aria-label="Field key"
174
+ placeholder="key"
175
+ value={row.key}
176
+ onChange={(e) =>
177
+ updateField(row.rowId, { key: e.target.value })
178
+ }
179
+ />
180
+ <Input
181
+ aria-label="Field value"
182
+ placeholder="value"
183
+ value={row.value}
184
+ onChange={(e) =>
185
+ updateField(row.rowId, { value: e.target.value })
186
+ }
187
+ />
188
+ <Button
189
+ type="button"
190
+ size="sm"
191
+ variant="ghost"
192
+ onClick={() => removeField(row.rowId)}
193
+ aria-label="Remove field"
194
+ >
195
+ <Trash2 className="w-4 h-4" />
196
+ </Button>
197
+ </div>
198
+ ))}
199
+ </div>
200
+ )}
201
+ </div>
202
+ </div>
203
+
204
+ <DialogFooter>
205
+ <Button type="button" variant="outline" onClick={onClose}>
206
+ Cancel
207
+ </Button>
208
+ <Button type="submit" disabled={loading || !name.trim()}>
209
+ {loading
210
+ ? "Saving..."
211
+ : initialData
212
+ ? "Save Changes"
213
+ : "Create Environment"}
214
+ </Button>
215
+ </DialogFooter>
216
+ </form>
217
+ </DialogContent>
218
+ </Dialog>
219
+ );
220
+ };
@@ -0,0 +1,61 @@
1
+ import React from "react";
2
+ import {
3
+ Select,
4
+ SelectContent,
5
+ SelectItem,
6
+ SelectTrigger,
7
+ SelectValue,
8
+ } from "@checkstack/ui";
9
+ import { Eye } from "lucide-react";
10
+ import type { Environment } from "@checkstack/catalog-common";
11
+ import { toPreviewOptions } from "./environment-preview.logic";
12
+
13
+ interface EnvironmentPreviewPickerProps {
14
+ /** Environments to choose from (the system's, or all when no system applies). */
15
+ environments: ReadonlyArray<Environment>;
16
+ /** Currently selected environment id, or null for "none". */
17
+ selectedId: string | null;
18
+ /** Called with the picked id, or null when the author clears the selection. */
19
+ onSelect: (environmentId: string | null) => void;
20
+ }
21
+
22
+ /**
23
+ * Unobtrusive "Preview as: &lt;environment&gt;" picker. Lets a config author
24
+ * pick a catalog environment so `x-templatable` fields (e.g. an HTTP URL using
25
+ * `{{ environment.baseUrl }}`) preview their resolved value live. Purely
26
+ * presentational: the host supplies the environment list + selection state.
27
+ * Renders nothing when there are no environments to preview against.
28
+ */
29
+ export const EnvironmentPreviewPicker: React.FC<
30
+ EnvironmentPreviewPickerProps
31
+ > = ({ environments, selectedId, onSelect }) => {
32
+ if (environments.length === 0) return null;
33
+
34
+ const options = toPreviewOptions(environments);
35
+ // Sentinel value for the "no environment" option — the underlying Select
36
+ // primitive cannot use an empty-string item value.
37
+ const NONE = "__none__";
38
+
39
+ return (
40
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
41
+ <Eye className="h-3.5 w-3.5 shrink-0" />
42
+ <span>Preview as:</span>
43
+ <Select
44
+ value={selectedId ?? NONE}
45
+ onValueChange={(value) => onSelect(value === NONE ? null : value)}
46
+ >
47
+ <SelectTrigger className="h-7 w-[180px] text-xs">
48
+ <SelectValue placeholder="No environment" />
49
+ </SelectTrigger>
50
+ <SelectContent>
51
+ <SelectItem value={NONE}>No environment</SelectItem>
52
+ {options.map((option) => (
53
+ <SelectItem key={option.id} value={option.id}>
54
+ {option.name}
55
+ </SelectItem>
56
+ ))}
57
+ </SelectContent>
58
+ </Select>
59
+ </div>
60
+ );
61
+ };
@@ -20,12 +20,47 @@ import {
20
20
  PageContent,
21
21
  PageLayout,
22
22
  LoadingSpinner,
23
- AccessDenied,
23
+ NotFound,
24
24
  } from "@checkstack/ui";
25
+ import { formatDate } from "../utils/formatDate.logic";
26
+ import {
27
+ normalizeMetadata,
28
+ type MetadataEntry,
29
+ } from "../utils/normalizeMetadata.logic";
25
30
  import { authApiRef } from "@checkstack/auth-frontend/api";
26
31
 
27
32
  import { Activity, Calendar, ExternalLink, Mail, User } from "lucide-react";
28
33
 
34
+ const MetadataSection: React.FC<{
35
+ metadata: Record<string, unknown> | null | undefined;
36
+ }> = ({ metadata }) => {
37
+ const entries: MetadataEntry[] = normalizeMetadata(metadata);
38
+ if (entries.length === 0) return null;
39
+
40
+ return (
41
+ <>
42
+ <div className="h-px bg-border" />
43
+ <div className="space-y-2">
44
+ <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
45
+ Metadata
46
+ </h3>
47
+ <dl className="space-y-1.5">
48
+ {entries.map(({ key, displayValue }) => (
49
+ <div key={key} className="flex gap-2 text-xs min-w-0">
50
+ <dt className="shrink-0 font-medium text-muted-foreground">
51
+ {key}
52
+ </dt>
53
+ <dd className="text-foreground truncate">
54
+ <code className="font-mono">{displayValue}</code>
55
+ </dd>
56
+ </div>
57
+ ))}
58
+ </dl>
59
+ </div>
60
+ </>
61
+ );
62
+ };
63
+
29
64
  export const SystemDetailPage: React.FC = () => {
30
65
  const { systemId } = useParams<{ systemId: string }>();
31
66
  const catalogClient = usePluginClient(CatalogApi);
@@ -43,10 +78,12 @@ export const SystemDetailPage: React.FC = () => {
43
78
  const { data: groupsData, isLoading: groupsLoading } =
44
79
  catalogClient.getGroups.useQuery({});
45
80
 
46
- // Fetch contacts for this system
81
+ // Fetch contacts for this system. Contacts carry PII (name/email), so the
82
+ // endpoint is authenticated-only; skip the request for anonymous viewers
83
+ // (it would 401) and fall back to the "No contacts assigned" empty state.
47
84
  const { data: contactsData } = catalogClient.getSystemContacts.useQuery(
48
85
  { systemId: systemId ?? "" },
49
- { enabled: !!systemId },
86
+ { enabled: !!systemId && !!session },
50
87
  );
51
88
 
52
89
  // Fetch additional links for this system
@@ -92,9 +129,7 @@ export const SystemDetailPage: React.FC = () => {
92
129
  return (
93
130
  <Page>
94
131
  <PageContent>
95
- <div className="max-w-3xl space-y-6">
96
- <AccessDenied />
97
- </div>
132
+ <NotFound message="This system doesn't exist or has been removed." />
98
133
  </PageContent>
99
134
  </Page>
100
135
  );
@@ -107,7 +142,9 @@ export const SystemDetailPage: React.FC = () => {
107
142
 
108
143
  const headerActions = (
109
144
  <div className="flex items-center gap-2">
110
- <ExtensionSlot slot={SystemStateBadgesSlot} context={{ system }} />
145
+ <div className="flex items-center gap-1">
146
+ <ExtensionSlot slot={SystemStateBadgesSlot} context={{ system }} />
147
+ </div>
111
148
  {session && (
112
149
  <NotificationSubscriptionsManager
113
150
  target={catalogSystemTarget}
@@ -149,21 +186,11 @@ export const SystemDetailPage: React.FC = () => {
149
186
  <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
150
187
  <span className="flex items-center gap-1.5">
151
188
  <Calendar className="h-3 w-3" />
152
- Created{" "}
153
- {new Date(system.createdAt).toLocaleDateString("en-US", {
154
- year: "numeric",
155
- month: "short",
156
- day: "numeric",
157
- })}
189
+ Created {formatDate(system.createdAt)}
158
190
  </span>
159
191
  <span className="flex items-center gap-1.5">
160
192
  <Calendar className="h-3 w-3" />
161
- Updated{" "}
162
- {new Date(system.updatedAt).toLocaleDateString("en-US", {
163
- year: "numeric",
164
- month: "short",
165
- day: "numeric",
166
- })}
193
+ Updated {formatDate(system.updatedAt)}
167
194
  </span>
168
195
  </div>
169
196
  </div>
@@ -267,21 +294,7 @@ export const SystemDetailPage: React.FC = () => {
267
294
  </div>
268
295
 
269
296
  {/* Metadata (conditional) */}
270
- {system.metadata &&
271
- typeof system.metadata === "object" &&
272
- Object.keys(system.metadata).length > 0 && (
273
- <>
274
- <div className="h-px bg-border" />
275
- <div className="space-y-2">
276
- <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
277
- Metadata
278
- </h3>
279
- <pre className="text-xs text-foreground bg-muted/30 p-3 rounded-md border border-border overflow-x-auto">
280
- {JSON.stringify(system.metadata, undefined, 2)}
281
- </pre>
282
- </div>
283
- </>
284
- )}
297
+ <MetadataSection metadata={system.metadata} />
285
298
  </CardContent>
286
299
  </Card>
287
300
  </div>
@@ -14,6 +14,7 @@ import {
14
14
  import { TeamAccessEditor } from "@checkstack/auth-frontend";
15
15
  import { ContactsEditor } from "./ContactsEditor";
16
16
  import { SystemLinksEditor } from "./SystemLinksEditor";
17
+ import { SystemEnvironmentsEditor } from "./SystemEnvironmentsEditor";
17
18
  import { ExtensionSlot } from "@checkstack/frontend-api";
18
19
  import { SystemEditorSlot } from "@checkstack/catalog-common";
19
20
  import { extractErrorMessage } from "@checkstack/common";
@@ -111,6 +112,11 @@ export const SystemEditor: React.FC<SystemEditorProps> = ({
111
112
  {/* Additional Links - only shown for existing systems */}
112
113
  {initialData?.id && <SystemLinksEditor systemId={initialData.id} />}
113
114
 
115
+ {/* Environment membership - only shown for existing systems */}
116
+ {initialData?.id && (
117
+ <SystemEnvironmentsEditor systemId={initialData.id} />
118
+ )}
119
+
114
120
  {/* Team Access Editor - only shown for existing systems */}
115
121
  {initialData?.id && (
116
122
  <TeamAccessEditor