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