@checkstack/gitops-frontend 0.2.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 +37 -0
- package/package.json +29 -0
- package/src/components/GitOpsLockBanner.tsx +35 -0
- package/src/components/GitOpsMenuItem.tsx +28 -0
- package/src/components/KindRegistryMenuItem.tsx +28 -0
- package/src/components/ProvenanceStatus.tsx +246 -0
- package/src/components/ProviderEditor.tsx +214 -0
- package/src/components/ProviderList.tsx +294 -0
- package/src/components/SecretEditor.tsx +110 -0
- package/src/components/SecretList.tsx +204 -0
- package/src/components/SecretRotateDialog.tsx +79 -0
- package/src/hooks/useProvenanceLock.ts +32 -0
- package/src/index.tsx +46 -0
- package/src/pages/GitOpsPage.tsx +56 -0
- package/src/pages/KindRegistryPage.tsx +516 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
2
|
+
import { GitOpsApi } from "@checkstack/gitops-common";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook to check if a catalog entity is managed by GitOps.
|
|
6
|
+
*
|
|
7
|
+
* Returns provenance data and loading state so callers can
|
|
8
|
+
* disable editing and show a lock banner.
|
|
9
|
+
*
|
|
10
|
+
* @param params.kind - Entity kind (e.g. "System", "Group")
|
|
11
|
+
* @param params.entityId - Plugin-specific entity ID (e.g. catalog system UUID)
|
|
12
|
+
*/
|
|
13
|
+
export const useProvenanceLock = (params: {
|
|
14
|
+
kind: string;
|
|
15
|
+
entityId: string | undefined;
|
|
16
|
+
}) => {
|
|
17
|
+
const gitopsClient = usePluginClient(GitOpsApi);
|
|
18
|
+
|
|
19
|
+
const { data: provenance, isLoading } = gitopsClient.getProvenance.useQuery(
|
|
20
|
+
{ kind: params.kind, entityId: params.entityId! },
|
|
21
|
+
{ enabled: !!params.entityId },
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
/** Whether this entity is managed by GitOps */
|
|
26
|
+
isLocked: !!provenance && provenance.status === "synced",
|
|
27
|
+
/** Full provenance data (or null) */
|
|
28
|
+
provenance,
|
|
29
|
+
/** Whether the provenance query is still loading */
|
|
30
|
+
isLoading,
|
|
31
|
+
};
|
|
32
|
+
};
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createFrontendPlugin,
|
|
3
|
+
createSlotExtension,
|
|
4
|
+
UserMenuItemsSlot,
|
|
5
|
+
} from "@checkstack/frontend-api";
|
|
6
|
+
import {
|
|
7
|
+
gitopsRoutes,
|
|
8
|
+
pluginMetadata,
|
|
9
|
+
gitopsAccess,
|
|
10
|
+
} from "@checkstack/gitops-common";
|
|
11
|
+
|
|
12
|
+
import { GitOpsPage } from "./pages/GitOpsPage";
|
|
13
|
+
import { KindRegistryPage } from "./pages/KindRegistryPage";
|
|
14
|
+
import { GitOpsMenuItem } from "./components/GitOpsMenuItem";
|
|
15
|
+
import { KindRegistryMenuItem } from "./components/KindRegistryMenuItem";
|
|
16
|
+
|
|
17
|
+
export const gitopsPlugin = createFrontendPlugin({
|
|
18
|
+
metadata: pluginMetadata,
|
|
19
|
+
apis: [],
|
|
20
|
+
routes: [
|
|
21
|
+
{
|
|
22
|
+
route: gitopsRoutes.routes.home,
|
|
23
|
+
element: <GitOpsPage />,
|
|
24
|
+
accessRule: gitopsAccess.provider.read,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
route: gitopsRoutes.routes.kinds,
|
|
28
|
+
element: <KindRegistryPage />,
|
|
29
|
+
accessRule: gitopsAccess.kinds.read,
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
extensions: [
|
|
33
|
+
createSlotExtension(UserMenuItemsSlot, {
|
|
34
|
+
id: "gitops.user-menu.link",
|
|
35
|
+
component: GitOpsMenuItem,
|
|
36
|
+
}),
|
|
37
|
+
createSlotExtension(UserMenuItemsSlot, {
|
|
38
|
+
id: "gitops.user-menu.kind-registry",
|
|
39
|
+
component: KindRegistryMenuItem,
|
|
40
|
+
}),
|
|
41
|
+
],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Public API for other frontend plugins
|
|
45
|
+
export { useProvenanceLock } from "./hooks/useProvenanceLock";
|
|
46
|
+
export { GitOpsLockBanner } from "./components/GitOpsLockBanner";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useApi, accessApiRef } from "@checkstack/frontend-api";
|
|
3
|
+
import { gitopsAccess } from "@checkstack/gitops-common";
|
|
4
|
+
import {
|
|
5
|
+
PageLayout,
|
|
6
|
+
Tabs,
|
|
7
|
+
TabPanel,
|
|
8
|
+
} from "@checkstack/ui";
|
|
9
|
+
import { GitBranch, KeyRound, Activity } from "lucide-react";
|
|
10
|
+
import { ProviderList } from "../components/ProviderList";
|
|
11
|
+
import { SecretList } from "../components/SecretList";
|
|
12
|
+
import { ProvenanceStatus } from "../components/ProvenanceStatus";
|
|
13
|
+
|
|
14
|
+
const TAB_ITEMS = [
|
|
15
|
+
{ id: "providers", label: "Providers", icon: <GitBranch className="w-4 h-4" /> },
|
|
16
|
+
{ id: "secrets", label: "Secrets", icon: <KeyRound className="w-4 h-4" /> },
|
|
17
|
+
{ id: "status", label: "Sync Status", icon: <Activity className="w-4 h-4" /> },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export const GitOpsPage = () => {
|
|
21
|
+
const accessApi = useApi(accessApiRef);
|
|
22
|
+
const [activeTab, setActiveTab] = useState("providers");
|
|
23
|
+
|
|
24
|
+
const { allowed: canRead, loading: accessLoading } = accessApi.useAccess(
|
|
25
|
+
gitopsAccess.provider.read,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<PageLayout
|
|
30
|
+
title="GitOps"
|
|
31
|
+
subtitle="Manage Git providers, secrets, and sync status for infrastructure-as-code"
|
|
32
|
+
icon={GitBranch}
|
|
33
|
+
loading={accessLoading}
|
|
34
|
+
allowed={canRead}
|
|
35
|
+
>
|
|
36
|
+
<Tabs
|
|
37
|
+
items={TAB_ITEMS}
|
|
38
|
+
activeTab={activeTab}
|
|
39
|
+
onTabChange={setActiveTab}
|
|
40
|
+
className="mb-6"
|
|
41
|
+
/>
|
|
42
|
+
|
|
43
|
+
<TabPanel id="providers" activeTab={activeTab}>
|
|
44
|
+
<ProviderList />
|
|
45
|
+
</TabPanel>
|
|
46
|
+
|
|
47
|
+
<TabPanel id="secrets" activeTab={activeTab}>
|
|
48
|
+
<SecretList />
|
|
49
|
+
</TabPanel>
|
|
50
|
+
|
|
51
|
+
<TabPanel id="status" activeTab={activeTab}>
|
|
52
|
+
<ProvenanceStatus />
|
|
53
|
+
</TabPanel>
|
|
54
|
+
</PageLayout>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Card,
|
|
4
|
+
CardContent,
|
|
5
|
+
CardDescription,
|
|
6
|
+
CardHeader,
|
|
7
|
+
CardTitle,
|
|
8
|
+
Badge,
|
|
9
|
+
PageLayout,
|
|
10
|
+
} from "@checkstack/ui";
|
|
11
|
+
import { ChevronDown, ChevronRight, Puzzle, Blocks } from "lucide-react";
|
|
12
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
13
|
+
import { GitOpsApi } from "@checkstack/gitops-common";
|
|
14
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
15
|
+
|
|
16
|
+
// ─── Types ─────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
interface JsonSchemaProperty {
|
|
19
|
+
type?: string;
|
|
20
|
+
properties?: Record<string, JsonSchemaProperty>;
|
|
21
|
+
items?: JsonSchemaProperty;
|
|
22
|
+
required?: string[];
|
|
23
|
+
description?: string;
|
|
24
|
+
enum?: string[];
|
|
25
|
+
anyOf?: JsonSchemaProperty[];
|
|
26
|
+
default?: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface KindDescription {
|
|
30
|
+
apiVersion: string;
|
|
31
|
+
kind: string;
|
|
32
|
+
specSchema: JsonSchemaProperty;
|
|
33
|
+
extensions: Array<{
|
|
34
|
+
namespace: string;
|
|
35
|
+
specSchema: JsonSchemaProperty;
|
|
36
|
+
}>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Schema Display ────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const TYPE_COLORS: Record<string, string> = {
|
|
42
|
+
string: "text-green-600 dark:text-green-400",
|
|
43
|
+
number: "text-amber-600 dark:text-amber-400",
|
|
44
|
+
integer: "text-amber-600 dark:text-amber-400",
|
|
45
|
+
boolean: "text-red-600 dark:text-red-400",
|
|
46
|
+
array: "text-blue-600 dark:text-blue-400",
|
|
47
|
+
object: "text-purple-600 dark:text-purple-400",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function SchemaPropertyDisplay({
|
|
51
|
+
schema,
|
|
52
|
+
depth = 0,
|
|
53
|
+
}: {
|
|
54
|
+
schema: JsonSchemaProperty;
|
|
55
|
+
depth?: number;
|
|
56
|
+
}) {
|
|
57
|
+
if (schema.enum) {
|
|
58
|
+
return (
|
|
59
|
+
<span className="text-green-600 dark:text-green-400">
|
|
60
|
+
{schema.enum.map((e) => `"${e}"`).join(" | ")}
|
|
61
|
+
</span>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (schema.anyOf) {
|
|
66
|
+
return (
|
|
67
|
+
<span>
|
|
68
|
+
{schema.anyOf.map((s, i) => (
|
|
69
|
+
<span key={i}>
|
|
70
|
+
{i > 0 && <span className="text-muted-foreground"> | </span>}
|
|
71
|
+
<SchemaPropertyDisplay schema={s} depth={depth} />
|
|
72
|
+
</span>
|
|
73
|
+
))}
|
|
74
|
+
</span>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (schema.type === "object" && schema.properties) {
|
|
79
|
+
return (
|
|
80
|
+
<div className="font-mono text-sm" style={{ marginLeft: depth * 16 }}>
|
|
81
|
+
{"{"}
|
|
82
|
+
{Object.entries(schema.properties).map(([key, value]) => (
|
|
83
|
+
<div key={key} className="ml-4">
|
|
84
|
+
<span className="text-blue-600 dark:text-blue-400">{key}</span>
|
|
85
|
+
{schema.required?.includes(key) && (
|
|
86
|
+
<span className="text-red-500">*</span>
|
|
87
|
+
)}
|
|
88
|
+
: <SchemaPropertyDisplay schema={value} depth={depth + 1} />
|
|
89
|
+
{value.description && (
|
|
90
|
+
<span className="text-muted-foreground ml-2 text-xs">
|
|
91
|
+
// {value.description}
|
|
92
|
+
</span>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
))}
|
|
96
|
+
{"}"}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (schema.type === "array" && schema.items) {
|
|
102
|
+
return (
|
|
103
|
+
<span>
|
|
104
|
+
<SchemaPropertyDisplay schema={schema.items} depth={depth} />
|
|
105
|
+
{"[]"}
|
|
106
|
+
</span>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const typeStr = schema.type ?? "unknown";
|
|
111
|
+
return (
|
|
112
|
+
<span className={TYPE_COLORS[typeStr] ?? "text-gray-600"}>
|
|
113
|
+
{typeStr}
|
|
114
|
+
{schema.default !== undefined && (
|
|
115
|
+
<span className="text-muted-foreground ml-1">
|
|
116
|
+
= {JSON.stringify(schema.default)}
|
|
117
|
+
</span>
|
|
118
|
+
)}
|
|
119
|
+
</span>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function SchemaBlock({
|
|
124
|
+
schema,
|
|
125
|
+
label,
|
|
126
|
+
}: {
|
|
127
|
+
schema: JsonSchemaProperty;
|
|
128
|
+
label: string;
|
|
129
|
+
}) {
|
|
130
|
+
const hasProperties =
|
|
131
|
+
schema.type === "object" &&
|
|
132
|
+
schema.properties &&
|
|
133
|
+
Object.keys(schema.properties).length > 0;
|
|
134
|
+
|
|
135
|
+
if (!hasProperties) {
|
|
136
|
+
return (
|
|
137
|
+
<div>
|
|
138
|
+
<h4 className="text-sm font-medium mb-2 text-muted-foreground">
|
|
139
|
+
{label}
|
|
140
|
+
</h4>
|
|
141
|
+
<div className="bg-muted rounded-md p-3 text-sm text-muted-foreground italic">
|
|
142
|
+
No properties defined
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div>
|
|
150
|
+
<h4 className="text-sm font-medium mb-2 text-muted-foreground">
|
|
151
|
+
{label}
|
|
152
|
+
</h4>
|
|
153
|
+
<div className="bg-muted rounded-md p-3 overflow-x-auto">
|
|
154
|
+
<SchemaPropertyDisplay schema={schema} />
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── YAML Example Generator ────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
function generateYamlExample({ kind }: { kind: KindDescription }): string {
|
|
163
|
+
const lines = [
|
|
164
|
+
`apiVersion: ${kind.apiVersion}`,
|
|
165
|
+
`kind: ${kind.kind}`,
|
|
166
|
+
"metadata:",
|
|
167
|
+
` name: my-${kind.kind.toLowerCase()}`,
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
const baseProps = kind.specSchema.properties ?? {};
|
|
171
|
+
const hasBaseProps = Object.keys(baseProps).length > 0;
|
|
172
|
+
const hasExtensions = kind.extensions.length > 0;
|
|
173
|
+
|
|
174
|
+
if (!hasBaseProps && !hasExtensions) {
|
|
175
|
+
lines.push("spec: {}");
|
|
176
|
+
return lines.join("\n");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
lines.push("spec:");
|
|
180
|
+
|
|
181
|
+
const baseRequired = new Set(kind.specSchema.required);
|
|
182
|
+
for (const [key, prop] of Object.entries(baseProps)) {
|
|
183
|
+
emitProperty({
|
|
184
|
+
lines,
|
|
185
|
+
key,
|
|
186
|
+
prop,
|
|
187
|
+
indent: 2,
|
|
188
|
+
required: baseRequired.has(key),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const ext of kind.extensions) {
|
|
193
|
+
emitProperty({
|
|
194
|
+
lines,
|
|
195
|
+
key: ext.namespace,
|
|
196
|
+
prop: ext.specSchema,
|
|
197
|
+
indent: 2,
|
|
198
|
+
required: false,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return lines.join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Recursively emits a YAML property with proper indentation.
|
|
207
|
+
* Annotates optional and nullable fields with inline comments.
|
|
208
|
+
*/
|
|
209
|
+
function emitProperty({
|
|
210
|
+
lines,
|
|
211
|
+
key,
|
|
212
|
+
prop,
|
|
213
|
+
indent,
|
|
214
|
+
required,
|
|
215
|
+
}: {
|
|
216
|
+
lines: string[];
|
|
217
|
+
key: string;
|
|
218
|
+
prop: JsonSchemaProperty;
|
|
219
|
+
indent: number;
|
|
220
|
+
required: boolean;
|
|
221
|
+
}) {
|
|
222
|
+
const pad = " ".repeat(indent);
|
|
223
|
+
const annotation = buildAnnotation({ prop, required });
|
|
224
|
+
|
|
225
|
+
// Resolve the effective schema (unwrap nullable / anyOf wrappers)
|
|
226
|
+
const effective = resolveEffective({ prop });
|
|
227
|
+
|
|
228
|
+
if (effective.type === "object" && effective.properties) {
|
|
229
|
+
lines.push(`${pad}${key}:${annotation}`);
|
|
230
|
+
const objRequired = new Set(effective.required);
|
|
231
|
+
for (const [k, p] of Object.entries(effective.properties)) {
|
|
232
|
+
emitProperty({
|
|
233
|
+
lines,
|
|
234
|
+
key: k,
|
|
235
|
+
prop: p,
|
|
236
|
+
indent: indent + 2,
|
|
237
|
+
required: objRequired.has(k),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
} else if (effective.type === "array") {
|
|
241
|
+
lines.push(`${pad}${key}:${annotation}`);
|
|
242
|
+
if (effective.items) {
|
|
243
|
+
emitArrayItem({ lines, itemSchema: effective.items, indent: indent + 2 });
|
|
244
|
+
} else {
|
|
245
|
+
lines.push(`${pad} - # ...`);
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
lines.push(`${pad}${key}: ${scalarExample({ prop })}${annotation}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Emits a single array item (the `- ` prefix) with proper handling of
|
|
254
|
+
* objects, nested arrays, and scalar values.
|
|
255
|
+
*/
|
|
256
|
+
function emitArrayItem({
|
|
257
|
+
lines,
|
|
258
|
+
itemSchema,
|
|
259
|
+
indent,
|
|
260
|
+
}: {
|
|
261
|
+
lines: string[];
|
|
262
|
+
itemSchema: JsonSchemaProperty;
|
|
263
|
+
indent: number;
|
|
264
|
+
}) {
|
|
265
|
+
const pad = " ".repeat(indent);
|
|
266
|
+
const effective = resolveEffective({ prop: itemSchema });
|
|
267
|
+
|
|
268
|
+
if (effective.type === "object" && effective.properties) {
|
|
269
|
+
const itemRequired = new Set(effective.required);
|
|
270
|
+
const entries = Object.entries(effective.properties);
|
|
271
|
+
for (const [i, [k, p]] of entries.entries()) {
|
|
272
|
+
const prefix = i === 0 ? `${pad}- ` : `${pad} `;
|
|
273
|
+
const itemAnnotation = buildAnnotation({
|
|
274
|
+
prop: p,
|
|
275
|
+
required: itemRequired.has(k),
|
|
276
|
+
});
|
|
277
|
+
const inner = resolveEffective({ prop: p });
|
|
278
|
+
|
|
279
|
+
if (inner.type === "object" && inner.properties) {
|
|
280
|
+
// Recurse into nested objects
|
|
281
|
+
lines.push(`${prefix}${k}:${itemAnnotation}`);
|
|
282
|
+
const nestedRequired = new Set(inner.required);
|
|
283
|
+
for (const [nk, np] of Object.entries(inner.properties)) {
|
|
284
|
+
emitProperty({
|
|
285
|
+
lines,
|
|
286
|
+
key: nk,
|
|
287
|
+
prop: np,
|
|
288
|
+
indent: indent + 4,
|
|
289
|
+
required: nestedRequired.has(nk),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
} else if (inner.type === "array") {
|
|
293
|
+
lines.push(`${prefix}${k}:${itemAnnotation}`);
|
|
294
|
+
if (inner.items) {
|
|
295
|
+
emitArrayItem({ lines, itemSchema: inner.items, indent: indent + 4 });
|
|
296
|
+
} else {
|
|
297
|
+
lines.push(`${" ".repeat(indent + 4)}- # ...`);
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
lines.push(
|
|
301
|
+
`${prefix}${k}: ${scalarExample({ prop: p })}${itemAnnotation}`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
} else {
|
|
306
|
+
lines.push(`${pad}- ${scalarExample({ prop: itemSchema })}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Builds an inline YAML comment annotation for optional/nullable fields.
|
|
312
|
+
*/
|
|
313
|
+
function buildAnnotation({
|
|
314
|
+
prop,
|
|
315
|
+
required,
|
|
316
|
+
}: {
|
|
317
|
+
prop: JsonSchemaProperty;
|
|
318
|
+
required: boolean;
|
|
319
|
+
}): string {
|
|
320
|
+
const tags: string[] = [];
|
|
321
|
+
if (!required) tags.push("optional");
|
|
322
|
+
if (isNullable({ prop })) tags.push("nullable");
|
|
323
|
+
return tags.length > 0 ? ` # ${tags.join(", ")}` : "";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Checks if a property allows null values (via anyOf with null type).
|
|
328
|
+
*/
|
|
329
|
+
function isNullable({ prop }: { prop: JsonSchemaProperty }): boolean {
|
|
330
|
+
if (prop.anyOf) {
|
|
331
|
+
return prop.anyOf.some((s) => s.type === "null");
|
|
332
|
+
}
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Resolves the effective schema by unwrapping nullable anyOf wrappers.
|
|
338
|
+
*/
|
|
339
|
+
function resolveEffective({
|
|
340
|
+
prop,
|
|
341
|
+
}: {
|
|
342
|
+
prop: JsonSchemaProperty;
|
|
343
|
+
}): JsonSchemaProperty {
|
|
344
|
+
if (prop.anyOf) {
|
|
345
|
+
const nonNull = prop.anyOf.filter((s) => s.type !== "null");
|
|
346
|
+
if (nonNull.length === 1) return nonNull[0];
|
|
347
|
+
}
|
|
348
|
+
return prop;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Returns a scalar YAML example value for a property.
|
|
353
|
+
* Objects without defined properties (z.record) get a comment annotation.
|
|
354
|
+
*/
|
|
355
|
+
function scalarExample({ prop }: { prop: JsonSchemaProperty }): string {
|
|
356
|
+
const effective = resolveEffective({ prop });
|
|
357
|
+
if (effective.enum) return `"${effective.enum[0]}"`;
|
|
358
|
+
if (effective.default !== undefined) return JSON.stringify(effective.default);
|
|
359
|
+
switch (effective.type) {
|
|
360
|
+
case "string": {
|
|
361
|
+
return '"..."';
|
|
362
|
+
}
|
|
363
|
+
case "number":
|
|
364
|
+
case "integer": {
|
|
365
|
+
return "0";
|
|
366
|
+
}
|
|
367
|
+
case "boolean": {
|
|
368
|
+
return "false";
|
|
369
|
+
}
|
|
370
|
+
case "array": {
|
|
371
|
+
return "[]";
|
|
372
|
+
}
|
|
373
|
+
case "object": {
|
|
374
|
+
// Object without properties = z.record() or z.unknown() — annotate
|
|
375
|
+
return "{} # key-value pairs";
|
|
376
|
+
}
|
|
377
|
+
default: {
|
|
378
|
+
return '"..."';
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ─── Kind Card ─────────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
function KindCard({ kind }: { kind: KindDescription }) {
|
|
386
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
387
|
+
|
|
388
|
+
return (
|
|
389
|
+
<Card className="mb-3">
|
|
390
|
+
<CardHeader
|
|
391
|
+
className="cursor-pointer hover:bg-accent/50 transition-colors py-4"
|
|
392
|
+
onClick={() => setIsOpen(!isOpen)}
|
|
393
|
+
>
|
|
394
|
+
<div className="flex items-center gap-3">
|
|
395
|
+
{isOpen ? (
|
|
396
|
+
<ChevronDown className="h-4 w-4 shrink-0" />
|
|
397
|
+
) : (
|
|
398
|
+
<ChevronRight className="h-4 w-4 shrink-0" />
|
|
399
|
+
)}
|
|
400
|
+
<Blocks className="h-5 w-5 text-blue-500 shrink-0" />
|
|
401
|
+
<CardTitle className="text-lg">{kind.kind}</CardTitle>
|
|
402
|
+
<Badge variant="secondary" className="font-mono text-xs">
|
|
403
|
+
{kind.apiVersion}
|
|
404
|
+
</Badge>
|
|
405
|
+
{kind.extensions.length > 0 && (
|
|
406
|
+
<Badge
|
|
407
|
+
variant="outline"
|
|
408
|
+
className="text-xs flex items-center gap-1"
|
|
409
|
+
>
|
|
410
|
+
<Puzzle className="h-3 w-3" />
|
|
411
|
+
{kind.extensions.length} extension
|
|
412
|
+
{kind.extensions.length > 1 ? "s" : ""}
|
|
413
|
+
</Badge>
|
|
414
|
+
)}
|
|
415
|
+
</div>
|
|
416
|
+
<CardDescription className="ml-8">
|
|
417
|
+
Entity kind with{" "}
|
|
418
|
+
{Object.keys(kind.specSchema.properties ?? {}).length} base properties
|
|
419
|
+
{kind.extensions.length > 0 &&
|
|
420
|
+
` and ${kind.extensions.length} extension namespace${kind.extensions.length > 1 ? "s" : ""}`}
|
|
421
|
+
</CardDescription>
|
|
422
|
+
</CardHeader>
|
|
423
|
+
|
|
424
|
+
{isOpen && (
|
|
425
|
+
<CardContent className="pt-0 space-y-6">
|
|
426
|
+
{/* Base Spec Schema */}
|
|
427
|
+
<SchemaBlock schema={kind.specSchema} label="Base Spec Schema" />
|
|
428
|
+
|
|
429
|
+
{/* Extensions */}
|
|
430
|
+
{kind.extensions.length > 0 && (
|
|
431
|
+
<div className="space-y-4">
|
|
432
|
+
<h4 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
|
433
|
+
<Puzzle className="h-4 w-4" />
|
|
434
|
+
Extensions
|
|
435
|
+
</h4>
|
|
436
|
+
{kind.extensions.map((ext) => (
|
|
437
|
+
<div
|
|
438
|
+
key={ext.namespace}
|
|
439
|
+
className="border rounded-lg p-4 space-y-3"
|
|
440
|
+
>
|
|
441
|
+
<div className="flex items-center gap-2">
|
|
442
|
+
<Badge variant="outline" className="font-mono">
|
|
443
|
+
{ext.namespace}
|
|
444
|
+
</Badge>
|
|
445
|
+
</div>
|
|
446
|
+
<div className="bg-muted rounded-md p-3 overflow-x-auto">
|
|
447
|
+
<SchemaPropertyDisplay schema={ext.specSchema} />
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
))}
|
|
451
|
+
</div>
|
|
452
|
+
)}
|
|
453
|
+
|
|
454
|
+
{/* YAML Example */}
|
|
455
|
+
<div>
|
|
456
|
+
<h4 className="text-sm font-medium mb-2 text-muted-foreground">
|
|
457
|
+
YAML Example
|
|
458
|
+
</h4>
|
|
459
|
+
<pre className="bg-muted rounded-md p-3 overflow-x-auto text-sm">
|
|
460
|
+
<code>{generateYamlExample({ kind })}</code>
|
|
461
|
+
</pre>
|
|
462
|
+
</div>
|
|
463
|
+
</CardContent>
|
|
464
|
+
)}
|
|
465
|
+
</Card>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ─── Page ──────────────────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
export function KindRegistryPage() {
|
|
472
|
+
const client = usePluginClient(GitOpsApi);
|
|
473
|
+
|
|
474
|
+
const { data: kinds, isLoading, error } = client.listKinds.useQuery({});
|
|
475
|
+
|
|
476
|
+
return (
|
|
477
|
+
<PageLayout
|
|
478
|
+
title="Entity Kind Registry"
|
|
479
|
+
subtitle="Browse registered entity kinds, their spec schemas, and extensions from all plugins"
|
|
480
|
+
icon={Blocks}
|
|
481
|
+
loading={isLoading}
|
|
482
|
+
maxWidth="full"
|
|
483
|
+
>
|
|
484
|
+
{error && (
|
|
485
|
+
<Card>
|
|
486
|
+
<CardHeader>
|
|
487
|
+
<CardTitle>Error Loading Kind Registry</CardTitle>
|
|
488
|
+
<CardDescription>
|
|
489
|
+
{extractErrorMessage(error, "Unknown error")}
|
|
490
|
+
</CardDescription>
|
|
491
|
+
</CardHeader>
|
|
492
|
+
</Card>
|
|
493
|
+
)}
|
|
494
|
+
|
|
495
|
+
{!isLoading && !error && (!kinds || kinds.length === 0) && (
|
|
496
|
+
<Card>
|
|
497
|
+
<CardContent className="py-8 text-center text-muted-foreground">
|
|
498
|
+
No entity kinds are registered. Kinds are registered by plugins
|
|
499
|
+
during startup.
|
|
500
|
+
</CardContent>
|
|
501
|
+
</Card>
|
|
502
|
+
)}
|
|
503
|
+
|
|
504
|
+
<div className="space-y-3">
|
|
505
|
+
{(kinds ?? [])
|
|
506
|
+
.toSorted((a, b) => a.kind.localeCompare(b.kind))
|
|
507
|
+
.map((kind) => (
|
|
508
|
+
<KindCard
|
|
509
|
+
key={`${kind.apiVersion}::${kind.kind}`}
|
|
510
|
+
kind={kind as KindDescription}
|
|
511
|
+
/>
|
|
512
|
+
))}
|
|
513
|
+
</div>
|
|
514
|
+
</PageLayout>
|
|
515
|
+
);
|
|
516
|
+
}
|