@checkstack/catalog-frontend 0.10.7 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +133 -0
- package/package.json +10 -9
- package/src/api.ts +6 -1
- package/src/components/CatalogConfigPage.tsx +337 -271
- package/src/components/CatalogPage.tsx +172 -11
- package/src/components/EnvironmentEditor.tsx +220 -0
- package/src/components/EnvironmentPreviewPicker.tsx +61 -0
- package/src/components/SystemDetailPage.tsx +47 -34
- package/src/components/SystemEditor.tsx +6 -0
- package/src/components/SystemEnvironmentsEditor.tsx +98 -0
- package/src/components/browse/CatalogBrowseHealth.tsx +36 -0
- package/src/components/browse/CatalogBrowseToolbar.tsx +173 -0
- package/src/components/browse/CatalogGroupSection.tsx +165 -0
- package/src/components/browse/CatalogSystemRow.tsx +63 -0
- package/src/components/browse/browseState.logic.test.ts +125 -0
- package/src/components/browse/browseState.logic.ts +158 -0
- package/src/components/browse/filterEntities.logic.test.ts +479 -0
- package/src/components/browse/filterEntities.logic.ts +360 -0
- package/src/components/browse/healthRollup.logic.test.ts +126 -0
- package/src/components/browse/healthRollup.logic.ts +120 -0
- package/src/components/browse/healthStatuses.logic.test.ts +39 -0
- package/src/components/browse/healthStatuses.logic.ts +29 -0
- package/src/components/environment-fields.logic.test.ts +111 -0
- package/src/components/environment-fields.logic.ts +98 -0
- package/src/components/environment-preview.logic.test.ts +76 -0
- package/src/components/environment-preview.logic.ts +61 -0
- package/src/components/manage/AssignMenu.tsx +78 -0
- package/src/components/manage/EnvironmentsTab.tsx +230 -0
- package/src/components/manage/GroupsTab.tsx +274 -0
- package/src/components/manage/SystemsTab.tsx +430 -0
- package/src/hooks/useCatalogBrowseState.ts +107 -0
- package/src/hooks/useDebouncedValue.ts +21 -0
- package/src/index.tsx +32 -20
- package/src/utils/formatDate.logic.test.ts +44 -0
- package/src/utils/formatDate.logic.ts +27 -0
- package/src/utils/normalizeMetadata.logic.test.ts +67 -0
- package/src/utils/normalizeMetadata.logic.ts +53 -0
- package/src/components/DraggableSystem.tsx +0 -200
- package/src/components/DroppableGroup.tsx +0 -174
- package/src/components/UserMenuItems.tsx +0 -31
|
@@ -1,18 +1,179 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
const
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
15
|
-
|
|
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: <environment>" 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
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|