@checkstack/gitops-frontend 0.2.0 → 0.3.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 +63 -0
- package/package.json +3 -3
- package/src/components/ProvenanceStatus.tsx +42 -1
- package/src/components/ProviderEditor.tsx +55 -9
- package/src/components/ProviderList.tsx +6 -2
- package/src/components/SecretEditor.tsx +21 -6
- package/src/components/SecretList.tsx +129 -53
- package/src/pages/KindRegistryPage.tsx +220 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,68 @@
|
|
|
1
1
|
# @checkstack/gitops-frontend
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8ef367a: Added `registerSpecSchemaDocumentation` to EntityKindRegistry to allow plugins to provide detailed JSON Schemas for specific configurations. The frontend now displays these registered schemas as dropdown alternatives, improving the developer experience when authoring GitOps configurations.
|
|
8
|
+
- cb65e9d: ### Schema-driven secret resolution, rotation invalidation, and security hardening
|
|
9
|
+
|
|
10
|
+
**Breaking**: Replaced `{ secretRef: "..." }` object syntax with `${{ secrets.NAME }}` template interpolation. The `secretField()`, `secretRefSchema`, `isSecretRef`, `SecretRef`, and `ResolvedSecretField` exports have been removed from `@checkstack/gitops-common`.
|
|
11
|
+
|
|
12
|
+
**Breaking**: `ReconcileContext.resolveSecretsBySchema()` now returns `{ resolved: T; warnings: string[] }` instead of `T` directly. Plugins must destructure the result. Warnings contain messages for `${{ secrets.NAME }}` templates found in non-secret fields (fields without `x-secret` annotation).
|
|
13
|
+
|
|
14
|
+
**New features**:
|
|
15
|
+
|
|
16
|
+
- Secrets can be referenced in **any string field** using `${{ secrets.NAME }}` syntax
|
|
17
|
+
- Inline interpolation is supported: `"postgres://user:${{ secrets.DB_PASS }}@host/db"`
|
|
18
|
+
- Resolution is **schema-driven** — reuses the existing `configString({ "x-secret": true })` pattern from DynamicForm
|
|
19
|
+
- Secret rotation now automatically invalidates affected entities, triggering re-reconciliation on the next sync cycle
|
|
20
|
+
- New `getSecretUsage` RPC endpoint to look up which entities reference a given secret
|
|
21
|
+
- Secrets UI now shows an expandable usage panel per secret showing referencing entities
|
|
22
|
+
- Reconciliation warnings: templates in non-secret fields are detected and surfaced in the provenance UI
|
|
23
|
+
- New `secretNameSchema` and `SECRET_NAME_REGEX` exports for validating secret names
|
|
24
|
+
|
|
25
|
+
**Security**:
|
|
26
|
+
|
|
27
|
+
- Secret names are validated at creation: must start with a letter, contain only `[a-zA-Z0-9_-]`, max 63 chars
|
|
28
|
+
- Secrets are validated to exist at sync time but **not pre-resolved** into the spec
|
|
29
|
+
- Templates in `metadata` fields are **rejected** to prevent secret leaks via display fields
|
|
30
|
+
- Only fields with `x-secret` schema annotations get resolved — no escape hatch
|
|
31
|
+
- Templates in non-secret fields emit warnings (stored in provenance, visible in UI) instead of silently passing
|
|
32
|
+
|
|
33
|
+
**Migration**: Update YAML descriptors to use `${{ secrets.NAME }}` instead of `secretRef: name`. Remove `secretField()` imports from plugin schemas — use `configString({ "x-secret": true })` to annotate secret fields. Destructure `const { resolved } = await context.resolveSecretsBySchema({ value, schema })` (return type changed from `T` to `{ resolved: T; warnings: string[] }`).
|
|
34
|
+
|
|
35
|
+
### Patch Changes
|
|
36
|
+
|
|
37
|
+
- Updated dependencies [8ef367a]
|
|
38
|
+
- Updated dependencies [cb65e9d]
|
|
39
|
+
- @checkstack/gitops-common@0.2.0
|
|
40
|
+
|
|
41
|
+
## 0.2.1
|
|
42
|
+
|
|
43
|
+
### Patch Changes
|
|
44
|
+
|
|
45
|
+
- 86bab6a: ### GitOps: Fix authentication token handling
|
|
46
|
+
|
|
47
|
+
- Made `authToken` optional in `ReconcileProviderParams` and `ScraperOptions` to support unauthenticated access to public repositories
|
|
48
|
+
- GitHub and GitLab scrapers now conditionally set authentication headers only when a token is provided
|
|
49
|
+
- Sync worker now decrypts the encrypted `authToken` from the database before passing it to scrapers, fixing authentication failures caused by sending encrypted values in HTTP headers
|
|
50
|
+
|
|
51
|
+
### SLO: Fix premature Nines Club achievement unlock
|
|
52
|
+
|
|
53
|
+
- The "Nines Club" achievement now requires both ≥99.99% availability **and** a 365-day compliance streak, preventing immediate unlock on newly created SLOs with 100% default availability
|
|
54
|
+
|
|
55
|
+
### SLO: Align frontend achievement descriptions with backend criteria
|
|
56
|
+
|
|
57
|
+
- Fixed mismatched descriptions for Iron Uptime (7-day, not 30), Diamond Uptime (30-day, not 90), Clean Sheet (rolling window, not quarter), Full Coverage (3+ SLOs, not all systems in group), and Nines Club (99.99%)
|
|
58
|
+
|
|
59
|
+
### SLO: Enrich milestones with system names
|
|
60
|
+
|
|
61
|
+
- The `getRecentMilestones` endpoint now resolves human-readable system names via the Catalog API instead of returning raw system IDs
|
|
62
|
+
|
|
63
|
+
- Updated dependencies [86bab6a]
|
|
64
|
+
- @checkstack/gitops-common@0.1.1
|
|
65
|
+
|
|
3
66
|
## 0.2.0
|
|
4
67
|
|
|
5
68
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/gitops-frontend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.tsx",
|
|
6
6
|
"checkstack": {
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"@checkstack/common": "0.6.5",
|
|
16
16
|
"@checkstack/frontend-api": "0.3.9",
|
|
17
|
-
"@checkstack/gitops-common": "0.
|
|
18
|
-
"@checkstack/ui": "1.3.
|
|
17
|
+
"@checkstack/gitops-common": "0.1.1",
|
|
18
|
+
"@checkstack/ui": "1.3.6",
|
|
19
19
|
"lucide-react": "^0.344.0",
|
|
20
20
|
"react": "^18.2.0",
|
|
21
21
|
"react-router-dom": "^6.22.0"
|
|
@@ -61,6 +61,9 @@ export const ProvenanceStatus = () => {
|
|
|
61
61
|
const synced = entries.filter((e) => e.status === "synced");
|
|
62
62
|
const errors = entries.filter((e) => e.status === "error");
|
|
63
63
|
const orphaned = entries.filter((e) => e.status === "orphaned");
|
|
64
|
+
const withWarnings = entries.filter(
|
|
65
|
+
(e) => e.warnings.length > 0 && e.status === "synced",
|
|
66
|
+
);
|
|
64
67
|
|
|
65
68
|
const statusIcon = (status: Provenance["status"]) => {
|
|
66
69
|
switch (status) {
|
|
@@ -107,6 +110,19 @@ export const ProvenanceStatus = () => {
|
|
|
107
110
|
{entry.errorMessage}
|
|
108
111
|
</div>
|
|
109
112
|
)}
|
|
113
|
+
{entry.warnings.length > 0 && (
|
|
114
|
+
<div className="mt-1 space-y-0.5">
|
|
115
|
+
{entry.warnings.map((warning, index) => (
|
|
116
|
+
<div
|
|
117
|
+
key={index}
|
|
118
|
+
className="text-xs text-amber-500 flex items-start gap-1"
|
|
119
|
+
>
|
|
120
|
+
<AlertTriangle className="w-3 h-3 shrink-0 mt-0.5" />
|
|
121
|
+
<span className="truncate">{warning}</span>
|
|
122
|
+
</div>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
110
126
|
</div>
|
|
111
127
|
</div>
|
|
112
128
|
|
|
@@ -148,7 +164,7 @@ export const ProvenanceStatus = () => {
|
|
|
148
164
|
<>
|
|
149
165
|
<div className="space-y-6">
|
|
150
166
|
{/* Summary */}
|
|
151
|
-
<div className="grid grid-cols-
|
|
167
|
+
<div className="grid grid-cols-4 gap-4">
|
|
152
168
|
<Card>
|
|
153
169
|
<CardContent className="pt-6">
|
|
154
170
|
<div className="flex items-center gap-2">
|
|
@@ -176,6 +192,15 @@ export const ProvenanceStatus = () => {
|
|
|
176
192
|
</div>
|
|
177
193
|
</CardContent>
|
|
178
194
|
</Card>
|
|
195
|
+
<Card>
|
|
196
|
+
<CardContent className="pt-6">
|
|
197
|
+
<div className="flex items-center gap-2">
|
|
198
|
+
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
|
199
|
+
<span className="text-2xl font-bold">{withWarnings.length}</span>
|
|
200
|
+
<span className="text-sm text-muted-foreground">Warnings</span>
|
|
201
|
+
</div>
|
|
202
|
+
</CardContent>
|
|
203
|
+
</Card>
|
|
179
204
|
</div>
|
|
180
205
|
|
|
181
206
|
{/* Orphaned entities — shown first if any */}
|
|
@@ -196,6 +221,22 @@ export const ProvenanceStatus = () => {
|
|
|
196
221
|
</Card>
|
|
197
222
|
)}
|
|
198
223
|
|
|
224
|
+
{/* Warnings */}
|
|
225
|
+
{withWarnings.length > 0 && (
|
|
226
|
+
<Card>
|
|
227
|
+
<CardHeader>
|
|
228
|
+
<CardTitle className="flex items-center gap-2">
|
|
229
|
+
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
|
230
|
+
Sync Warnings
|
|
231
|
+
</CardTitle>
|
|
232
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
233
|
+
These entities contain secret templates in non-secret fields. The templates will not be resolved.
|
|
234
|
+
</p>
|
|
235
|
+
</CardHeader>
|
|
236
|
+
<CardContent>{renderEntryList(withWarnings, false)}</CardContent>
|
|
237
|
+
</Card>
|
|
238
|
+
)}
|
|
239
|
+
|
|
199
240
|
{/* Errors */}
|
|
200
241
|
{errors.length > 0 && (
|
|
201
242
|
<Card>
|
|
@@ -24,7 +24,7 @@ interface ProviderEditorProps {
|
|
|
24
24
|
target: string;
|
|
25
25
|
pathPattern: string;
|
|
26
26
|
baseUrl?: string;
|
|
27
|
-
authToken?: string;
|
|
27
|
+
authToken?: string | null;
|
|
28
28
|
syncInterval?: number;
|
|
29
29
|
deletionPolicy?: "orphan" | "auto";
|
|
30
30
|
}) => void;
|
|
@@ -50,6 +50,7 @@ export const ProviderEditor: React.FC<ProviderEditorProps> = ({
|
|
|
50
50
|
const [pathPattern, setPathPattern] = useState(initialData?.pathPattern ?? ".checkstack/**/*.yaml");
|
|
51
51
|
const [baseUrl, setBaseUrl] = useState(initialData?.baseUrl ?? "");
|
|
52
52
|
const [authToken, setAuthToken] = useState("");
|
|
53
|
+
const [clearToken, setClearToken] = useState(false);
|
|
53
54
|
const [syncInterval, setSyncInterval] = useState(String(initialData?.syncInterval ?? 300));
|
|
54
55
|
const [deletionPolicy, setDeletionPolicy] = useState<"orphan" | "auto">(
|
|
55
56
|
initialData?.deletionPolicy ?? "orphan",
|
|
@@ -62,6 +63,7 @@ export const ProviderEditor: React.FC<ProviderEditorProps> = ({
|
|
|
62
63
|
setPathPattern(initialData?.pathPattern ?? ".checkstack/**/*.yaml");
|
|
63
64
|
setBaseUrl(initialData?.baseUrl ?? "");
|
|
64
65
|
setAuthToken("");
|
|
66
|
+
setClearToken(false);
|
|
65
67
|
setSyncInterval(String(initialData?.syncInterval ?? 300));
|
|
66
68
|
setDeletionPolicy(initialData?.deletionPolicy ?? "orphan");
|
|
67
69
|
}
|
|
@@ -71,12 +73,24 @@ export const ProviderEditor: React.FC<ProviderEditorProps> = ({
|
|
|
71
73
|
e.preventDefault();
|
|
72
74
|
if (!target.trim() || !pathPattern.trim()) return;
|
|
73
75
|
|
|
76
|
+
// Determine authToken value:
|
|
77
|
+
// - clearToken=true → null (explicitly remove)
|
|
78
|
+
// - non-empty string → new token
|
|
79
|
+
// - empty string → undefined (keep current)
|
|
80
|
+
let authTokenValue: string | null | undefined;
|
|
81
|
+
if (clearToken) {
|
|
82
|
+
// eslint-disable-next-line unicorn/no-null
|
|
83
|
+
authTokenValue = null;
|
|
84
|
+
} else if (authToken.trim()) {
|
|
85
|
+
authTokenValue = authToken.trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
74
88
|
onSave({
|
|
75
89
|
type,
|
|
76
90
|
target: target.trim(),
|
|
77
91
|
pathPattern: pathPattern.trim(),
|
|
78
92
|
baseUrl: baseUrl.trim() || undefined,
|
|
79
|
-
authToken:
|
|
93
|
+
authToken: authTokenValue,
|
|
80
94
|
syncInterval: Number(syncInterval) || 300,
|
|
81
95
|
deletionPolicy,
|
|
82
96
|
});
|
|
@@ -160,13 +174,45 @@ export const ProviderEditor: React.FC<ProviderEditorProps> = ({
|
|
|
160
174
|
<Label htmlFor="provider-auth-token">
|
|
161
175
|
Auth Token {initialData ? "(leave empty to keep current)" : "(optional)"}
|
|
162
176
|
</Label>
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
177
|
+
{clearToken ? (
|
|
178
|
+
<div className="flex items-center gap-2 p-2 rounded-md border border-border bg-muted/50">
|
|
179
|
+
<span className="text-sm text-muted-foreground flex-1">
|
|
180
|
+
Token will be removed on save
|
|
181
|
+
</span>
|
|
182
|
+
<Button
|
|
183
|
+
type="button"
|
|
184
|
+
variant="outline"
|
|
185
|
+
size="sm"
|
|
186
|
+
onClick={() => setClearToken(false)}
|
|
187
|
+
>
|
|
188
|
+
Undo
|
|
189
|
+
</Button>
|
|
190
|
+
</div>
|
|
191
|
+
) : (
|
|
192
|
+
<div className="flex gap-2">
|
|
193
|
+
<Input
|
|
194
|
+
id="provider-auth-token"
|
|
195
|
+
type="password"
|
|
196
|
+
placeholder="ghp_xxxx..."
|
|
197
|
+
value={authToken}
|
|
198
|
+
onChange={(e) => setAuthToken(e.target.value)}
|
|
199
|
+
className="flex-1"
|
|
200
|
+
/>
|
|
201
|
+
{initialData && (
|
|
202
|
+
<Button
|
|
203
|
+
type="button"
|
|
204
|
+
variant="outline"
|
|
205
|
+
size="sm"
|
|
206
|
+
onClick={() => {
|
|
207
|
+
setAuthToken("");
|
|
208
|
+
setClearToken(true);
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
Remove
|
|
212
|
+
</Button>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
170
216
|
</div>
|
|
171
217
|
|
|
172
218
|
<div className="grid grid-cols-2 gap-4">
|
|
@@ -119,7 +119,7 @@ export const ProviderList = () => {
|
|
|
119
119
|
target: string;
|
|
120
120
|
pathPattern: string;
|
|
121
121
|
baseUrl?: string;
|
|
122
|
-
authToken?: string;
|
|
122
|
+
authToken?: string | null;
|
|
123
123
|
syncInterval?: number;
|
|
124
124
|
deletionPolicy?: "orphan" | "auto";
|
|
125
125
|
}) => {
|
|
@@ -136,7 +136,11 @@ export const ProviderList = () => {
|
|
|
136
136
|
},
|
|
137
137
|
});
|
|
138
138
|
} else {
|
|
139
|
-
|
|
139
|
+
// On create, authToken is always string|undefined (Remove button not shown)
|
|
140
|
+
createMutation.mutate({
|
|
141
|
+
...data,
|
|
142
|
+
authToken: data.authToken ?? undefined,
|
|
143
|
+
});
|
|
140
144
|
}
|
|
141
145
|
};
|
|
142
146
|
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
DialogTitle,
|
|
11
11
|
DialogFooter,
|
|
12
12
|
} from "@checkstack/ui";
|
|
13
|
+
import { SECRET_NAME_REGEX } from "@checkstack/gitops-common";
|
|
13
14
|
|
|
14
15
|
interface SecretEditorProps {
|
|
15
16
|
open: boolean;
|
|
@@ -34,9 +35,18 @@ export const SecretEditor: React.FC<SecretEditorProps> = ({
|
|
|
34
35
|
}
|
|
35
36
|
}, [open]);
|
|
36
37
|
|
|
38
|
+
const nameError =
|
|
39
|
+
name.length > 0 && !SECRET_NAME_REGEX.test(name)
|
|
40
|
+
? "Must start with a letter and contain only letters, digits, underscores, or hyphens"
|
|
41
|
+
: name.length > 63
|
|
42
|
+
? "Must be 63 characters or fewer"
|
|
43
|
+
: undefined;
|
|
44
|
+
|
|
45
|
+
const canSubmit = name.trim().length > 0 && !nameError && value.trim().length > 0;
|
|
46
|
+
|
|
37
47
|
const handleSubmit = (e: React.FormEvent) => {
|
|
38
48
|
e.preventDefault();
|
|
39
|
-
if (!
|
|
49
|
+
if (!canSubmit) return;
|
|
40
50
|
|
|
41
51
|
onSave({
|
|
42
52
|
name: name.trim(),
|
|
@@ -64,12 +74,16 @@ export const SecretEditor: React.FC<SecretEditorProps> = ({
|
|
|
64
74
|
placeholder="e.g. GITHUB_TOKEN"
|
|
65
75
|
value={name}
|
|
66
76
|
onChange={(e) => setName(e.target.value)}
|
|
67
|
-
className=
|
|
77
|
+
className={`font-mono ${nameError ? "border-destructive" : ""}`}
|
|
68
78
|
required
|
|
69
79
|
/>
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
80
|
+
{nameError ? (
|
|
81
|
+
<p className="text-xs text-destructive">{nameError}</p>
|
|
82
|
+
) : (
|
|
83
|
+
<p className="text-xs text-muted-foreground">
|
|
84
|
+
Referenced as <code className="text-xs">{"${{ secrets.NAME }}"}</code> in descriptors.
|
|
85
|
+
</p>
|
|
86
|
+
)}
|
|
73
87
|
</div>
|
|
74
88
|
|
|
75
89
|
<div className="space-y-2">
|
|
@@ -99,7 +113,7 @@ export const SecretEditor: React.FC<SecretEditorProps> = ({
|
|
|
99
113
|
<Button type="button" variant="outline" onClick={onClose}>
|
|
100
114
|
Cancel
|
|
101
115
|
</Button>
|
|
102
|
-
<Button type="submit" disabled={!
|
|
116
|
+
<Button type="submit" disabled={!canSubmit}>
|
|
103
117
|
Create Secret
|
|
104
118
|
</Button>
|
|
105
119
|
</DialogFooter>
|
|
@@ -108,3 +122,4 @@ export const SecretEditor: React.FC<SecretEditorProps> = ({
|
|
|
108
122
|
</Dialog>
|
|
109
123
|
);
|
|
110
124
|
};
|
|
125
|
+
|
|
@@ -15,14 +15,56 @@ import {
|
|
|
15
15
|
EmptyState,
|
|
16
16
|
ConfirmationModal,
|
|
17
17
|
useToast,
|
|
18
|
+
Badge,
|
|
18
19
|
} from "@checkstack/ui";
|
|
19
|
-
import { Plus, RotateCw, Trash2, KeyRound } from "lucide-react";
|
|
20
|
+
import { Plus, RotateCw, Trash2, KeyRound, ChevronDown, ChevronRight } from "lucide-react";
|
|
20
21
|
import { extractErrorMessage } from "@checkstack/common";
|
|
21
22
|
import { SecretEditor } from "./SecretEditor";
|
|
22
23
|
import { SecretRotateDialog } from "./SecretRotateDialog";
|
|
23
24
|
|
|
24
25
|
const formatDate = (date: Date) => new Date(date).toLocaleString();
|
|
25
26
|
|
|
27
|
+
/** Expandable usage panel for a single secret. */
|
|
28
|
+
const SecretUsagePanel = ({ secretName }: { secretName: string }) => {
|
|
29
|
+
const client = usePluginClient(GitOpsApi);
|
|
30
|
+
const { data: usage, isLoading } = client.getSecretUsage.useQuery({
|
|
31
|
+
secretName,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (isLoading) {
|
|
35
|
+
return (
|
|
36
|
+
<div className="text-xs text-muted-foreground py-2 px-4">Loading…</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!usage || usage.length === 0) {
|
|
41
|
+
return (
|
|
42
|
+
<div className="text-xs text-muted-foreground py-2 px-4">
|
|
43
|
+
Not referenced by any entities.
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="px-4 pb-3 space-y-1">
|
|
50
|
+
{usage.map((entry) => (
|
|
51
|
+
<div
|
|
52
|
+
key={`${entry.kind}::${entry.entityName}`}
|
|
53
|
+
className="flex items-center gap-2 text-xs text-muted-foreground"
|
|
54
|
+
>
|
|
55
|
+
<Badge variant="outline" className="text-[10px] font-mono px-1.5 py-0">
|
|
56
|
+
{entry.kind}
|
|
57
|
+
</Badge>
|
|
58
|
+
<span className="font-medium text-foreground">{entry.entityName}</span>
|
|
59
|
+
<span className="hidden sm:inline truncate">
|
|
60
|
+
{entry.repository}/{entry.filePath}
|
|
61
|
+
</span>
|
|
62
|
+
</div>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
26
68
|
export const SecretList = () => {
|
|
27
69
|
const client = usePluginClient(GitOpsApi);
|
|
28
70
|
const accessApi = useApi(accessApiRef);
|
|
@@ -42,6 +84,9 @@ export const SecretList = () => {
|
|
|
42
84
|
secretId: string;
|
|
43
85
|
secretName: string;
|
|
44
86
|
}>({ isOpen: false, secretId: "", secretName: "" });
|
|
87
|
+
const [expandedSecrets, setExpandedSecrets] = useState<Set<string>>(
|
|
88
|
+
new Set(),
|
|
89
|
+
);
|
|
45
90
|
|
|
46
91
|
const { data: secrets, isLoading, refetch } = client.listSecrets.useQuery({});
|
|
47
92
|
|
|
@@ -78,6 +123,18 @@ export const SecretList = () => {
|
|
|
78
123
|
},
|
|
79
124
|
});
|
|
80
125
|
|
|
126
|
+
const toggleExpanded = (secretId: string) => {
|
|
127
|
+
setExpandedSecrets((prev) => {
|
|
128
|
+
const next = new Set(prev);
|
|
129
|
+
if (next.has(secretId)) {
|
|
130
|
+
next.delete(secretId);
|
|
131
|
+
} else {
|
|
132
|
+
next.add(secretId);
|
|
133
|
+
}
|
|
134
|
+
return next;
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
|
|
81
138
|
return (
|
|
82
139
|
<>
|
|
83
140
|
<Card>
|
|
@@ -108,64 +165,83 @@ export const SecretList = () => {
|
|
|
108
165
|
/>
|
|
109
166
|
) : (
|
|
110
167
|
<div className="space-y-3">
|
|
111
|
-
{secrets.map((secret) =>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
<div className="
|
|
119
|
-
<
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
168
|
+
{secrets.map((secret) => {
|
|
169
|
+
const isExpanded = expandedSecrets.has(secret.id);
|
|
170
|
+
return (
|
|
171
|
+
<div
|
|
172
|
+
key={secret.id}
|
|
173
|
+
className="rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-colors"
|
|
174
|
+
>
|
|
175
|
+
<div className="flex items-center justify-between p-4">
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
className="flex items-center gap-3 min-w-0 cursor-pointer bg-transparent border-none p-0 text-left"
|
|
179
|
+
onClick={() => toggleExpanded(secret.id)}
|
|
180
|
+
title={isExpanded ? "Hide usage" : "Show usage"}
|
|
181
|
+
>
|
|
182
|
+
{isExpanded ? (
|
|
183
|
+
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
|
184
|
+
) : (
|
|
185
|
+
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
|
186
|
+
)}
|
|
187
|
+
<KeyRound className="w-4 h-4 text-muted-foreground shrink-0" />
|
|
188
|
+
<div className="min-w-0">
|
|
189
|
+
<div className="font-medium font-mono text-sm">
|
|
190
|
+
{secret.name}
|
|
191
|
+
</div>
|
|
192
|
+
{secret.description && (
|
|
193
|
+
<div className="text-xs text-muted-foreground mt-0.5 truncate">
|
|
194
|
+
{secret.description}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
125
197
|
</div>
|
|
126
|
-
|
|
127
|
-
</div>
|
|
128
|
-
</div>
|
|
198
|
+
</button>
|
|
129
199
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
200
|
+
<div className="flex items-center gap-4 shrink-0">
|
|
201
|
+
<div className="text-right text-xs text-muted-foreground hidden md:block">
|
|
202
|
+
<div>Updated: {formatDate(secret.updatedAt)}</div>
|
|
203
|
+
</div>
|
|
134
204
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
205
|
+
{canManage && (
|
|
206
|
+
<div className="flex items-center gap-1">
|
|
207
|
+
<Button
|
|
208
|
+
variant="ghost"
|
|
209
|
+
size="icon"
|
|
210
|
+
onClick={() =>
|
|
211
|
+
setRotatingSecret({
|
|
212
|
+
id: secret.id,
|
|
213
|
+
name: secret.name,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
title="Rotate secret"
|
|
217
|
+
>
|
|
218
|
+
<RotateCw className="w-4 h-4" />
|
|
219
|
+
</Button>
|
|
220
|
+
<Button
|
|
221
|
+
variant="ghost"
|
|
222
|
+
size="icon"
|
|
223
|
+
onClick={() =>
|
|
224
|
+
setConfirmModal({
|
|
225
|
+
isOpen: true,
|
|
226
|
+
secretId: secret.id,
|
|
227
|
+
secretName: secret.name,
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
title="Delete secret"
|
|
231
|
+
>
|
|
232
|
+
<Trash2 className="w-4 h-4" />
|
|
233
|
+
</Button>
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
164
236
|
</div>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
{isExpanded && (
|
|
240
|
+
<SecretUsagePanel secretName={secret.name} />
|
|
165
241
|
)}
|
|
166
242
|
</div>
|
|
167
|
-
|
|
168
|
-
)
|
|
243
|
+
);
|
|
244
|
+
})}
|
|
169
245
|
</div>
|
|
170
246
|
)}
|
|
171
247
|
</CardContent>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useState, useCallback, useMemo, useEffect } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Card,
|
|
4
4
|
CardContent,
|
|
@@ -7,8 +7,22 @@ import {
|
|
|
7
7
|
CardTitle,
|
|
8
8
|
Badge,
|
|
9
9
|
PageLayout,
|
|
10
|
+
Select,
|
|
11
|
+
SelectContent,
|
|
12
|
+
SelectItem,
|
|
13
|
+
SelectTrigger,
|
|
14
|
+
SelectValue,
|
|
15
|
+
CodeEditor,
|
|
16
|
+
Markdown,
|
|
17
|
+
MarkdownBlock,
|
|
10
18
|
} from "@checkstack/ui";
|
|
11
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
ChevronDown,
|
|
21
|
+
ChevronRight,
|
|
22
|
+
Puzzle,
|
|
23
|
+
Blocks,
|
|
24
|
+
BookOpen,
|
|
25
|
+
} from "lucide-react";
|
|
12
26
|
import { usePluginClient } from "@checkstack/frontend-api";
|
|
13
27
|
import { GitOpsApi } from "@checkstack/gitops-common";
|
|
14
28
|
import { extractErrorMessage } from "@checkstack/common";
|
|
@@ -29,11 +43,23 @@ interface JsonSchemaProperty {
|
|
|
29
43
|
interface KindDescription {
|
|
30
44
|
apiVersion: string;
|
|
31
45
|
kind: string;
|
|
46
|
+
metadataSchema: JsonSchemaProperty;
|
|
32
47
|
specSchema: JsonSchemaProperty;
|
|
33
48
|
extensions: Array<{
|
|
34
49
|
namespace: string;
|
|
35
50
|
specSchema: JsonSchemaProperty;
|
|
36
51
|
}>;
|
|
52
|
+
specSchemaDocumentation?: Array<{
|
|
53
|
+
fieldPath: string;
|
|
54
|
+
variantId?: string;
|
|
55
|
+
label: string;
|
|
56
|
+
description?: string;
|
|
57
|
+
specSchema: JsonSchemaProperty;
|
|
58
|
+
conditions?: Array<{
|
|
59
|
+
fieldPath: string;
|
|
60
|
+
variantIds: string[];
|
|
61
|
+
}>;
|
|
62
|
+
}>;
|
|
37
63
|
}
|
|
38
64
|
|
|
39
65
|
// ─── Schema Display ────────────────────────────────────────────────────────
|
|
@@ -87,8 +113,11 @@ function SchemaPropertyDisplay({
|
|
|
87
113
|
)}
|
|
88
114
|
: <SchemaPropertyDisplay schema={value} depth={depth + 1} />
|
|
89
115
|
{value.description && (
|
|
90
|
-
<span className="text-muted-foreground ml-2 text-xs">
|
|
91
|
-
//
|
|
116
|
+
<span className="text-muted-foreground ml-2 text-xs inline-flex items-center gap-1">
|
|
117
|
+
//{" "}
|
|
118
|
+
<Markdown size="sm" className="inline">
|
|
119
|
+
{value.description}
|
|
120
|
+
</Markdown>
|
|
92
121
|
</span>
|
|
93
122
|
)}
|
|
94
123
|
</div>
|
|
@@ -160,12 +189,31 @@ function SchemaBlock({
|
|
|
160
189
|
// ─── YAML Example Generator ────────────────────────────────────────────────
|
|
161
190
|
|
|
162
191
|
function generateYamlExample({ kind }: { kind: KindDescription }): string {
|
|
163
|
-
const lines = [
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
"metadata:"
|
|
167
|
-
|
|
168
|
-
|
|
192
|
+
const lines = [`apiVersion: ${kind.apiVersion}`, `kind: ${kind.kind}`];
|
|
193
|
+
|
|
194
|
+
if (kind.metadataSchema) {
|
|
195
|
+
lines.push("metadata:");
|
|
196
|
+
const metadataProps = kind.metadataSchema.properties ?? {};
|
|
197
|
+
const metadataRequired = new Set(kind.metadataSchema.required);
|
|
198
|
+
|
|
199
|
+
for (const [key, prop] of Object.entries(metadataProps)) {
|
|
200
|
+
// Provide a nice default for name instead of generic "..."
|
|
201
|
+
const customProp =
|
|
202
|
+
key === "name"
|
|
203
|
+
? { ...prop, default: `my-${kind.kind.toLowerCase()}` }
|
|
204
|
+
: prop;
|
|
205
|
+
|
|
206
|
+
emitProperty({
|
|
207
|
+
lines,
|
|
208
|
+
key,
|
|
209
|
+
prop: customProp,
|
|
210
|
+
indent: 2,
|
|
211
|
+
required: metadataRequired.has(key),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
lines.push("metadata:", ` name: my-${kind.kind.toLowerCase()}`);
|
|
216
|
+
}
|
|
169
217
|
|
|
170
218
|
const baseProps = kind.specSchema.properties ?? {};
|
|
171
219
|
const hasBaseProps = Object.keys(baseProps).length > 0;
|
|
@@ -380,10 +428,149 @@ function scalarExample({ prop }: { prop: JsonSchemaProperty }): string {
|
|
|
380
428
|
}
|
|
381
429
|
}
|
|
382
430
|
|
|
431
|
+
// ─── Spec Schema Documentation ─────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
function SpecSchemaDocumentationSection({
|
|
434
|
+
docs,
|
|
435
|
+
}: {
|
|
436
|
+
docs: NonNullable<KindDescription["specSchemaDocumentation"]>;
|
|
437
|
+
}) {
|
|
438
|
+
const [selections, setSelections] = useState<Record<string, string>>({});
|
|
439
|
+
|
|
440
|
+
const handleSelect = useCallback((fieldPath: string, variantId: string) => {
|
|
441
|
+
setSelections((prev) => {
|
|
442
|
+
if (prev[fieldPath] === variantId) return prev;
|
|
443
|
+
return { ...prev, [fieldPath]: variantId };
|
|
444
|
+
});
|
|
445
|
+
}, []);
|
|
446
|
+
|
|
447
|
+
const groupedDocs: Record<string, typeof docs> = {};
|
|
448
|
+
for (const doc of docs) {
|
|
449
|
+
if (!groupedDocs[doc.fieldPath]) {
|
|
450
|
+
groupedDocs[doc.fieldPath] = [];
|
|
451
|
+
}
|
|
452
|
+
groupedDocs[doc.fieldPath].push(doc);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return (
|
|
456
|
+
<div className="space-y-6">
|
|
457
|
+
<h4 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
|
458
|
+
<BookOpen className="h-4 w-4" />
|
|
459
|
+
Additional Schemas
|
|
460
|
+
</h4>
|
|
461
|
+
|
|
462
|
+
{Object.entries(groupedDocs).map(([fieldPath, fieldDocs]) => {
|
|
463
|
+
return (
|
|
464
|
+
<SpecSchemaDocumentationField
|
|
465
|
+
key={fieldPath}
|
|
466
|
+
fieldPath={fieldPath}
|
|
467
|
+
docs={fieldDocs.toSorted((a, b) => a.label.localeCompare(b.label))}
|
|
468
|
+
selections={selections}
|
|
469
|
+
onSelect={handleSelect}
|
|
470
|
+
/>
|
|
471
|
+
);
|
|
472
|
+
})}
|
|
473
|
+
</div>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function SpecSchemaDocumentationField({
|
|
478
|
+
fieldPath,
|
|
479
|
+
docs,
|
|
480
|
+
selections,
|
|
481
|
+
onSelect,
|
|
482
|
+
}: {
|
|
483
|
+
fieldPath: string;
|
|
484
|
+
docs: NonNullable<KindDescription["specSchemaDocumentation"]>;
|
|
485
|
+
selections: Record<string, string>;
|
|
486
|
+
onSelect: (fieldPath: string, variantId: string) => void;
|
|
487
|
+
}) {
|
|
488
|
+
const availableDocs = useMemo(() => {
|
|
489
|
+
return docs.filter((doc) => {
|
|
490
|
+
if (!doc.conditions || doc.conditions.length === 0) return true;
|
|
491
|
+
return doc.conditions.every((cond) => {
|
|
492
|
+
const selectedForField = selections[cond.fieldPath];
|
|
493
|
+
if (!selectedForField) return false;
|
|
494
|
+
return cond.variantIds.includes(selectedForField);
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
}, [docs, selections]);
|
|
498
|
+
|
|
499
|
+
const currentSelection = selections[fieldPath] || "";
|
|
500
|
+
const isValidSelection =
|
|
501
|
+
currentSelection !== "" &&
|
|
502
|
+
availableDocs.some((d) => (d.variantId || d.label) === currentSelection);
|
|
503
|
+
|
|
504
|
+
useEffect(() => {
|
|
505
|
+
if (currentSelection !== "" && !isValidSelection) {
|
|
506
|
+
onSelect(fieldPath, "");
|
|
507
|
+
}
|
|
508
|
+
}, [currentSelection, isValidSelection, onSelect, fieldPath]);
|
|
509
|
+
|
|
510
|
+
if (availableDocs.length === 0) {
|
|
511
|
+
return <></>;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const selectedDoc = availableDocs.find(
|
|
515
|
+
(d) => (d.variantId || d.label) === currentSelection,
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
return (
|
|
519
|
+
<div className="border rounded-lg p-4 space-y-4">
|
|
520
|
+
<div className="flex items-center justify-between gap-4 flex-wrap">
|
|
521
|
+
<div className="flex items-center gap-2">
|
|
522
|
+
<Badge variant="secondary" className="font-mono">
|
|
523
|
+
{fieldPath}
|
|
524
|
+
</Badge>
|
|
525
|
+
<span className="text-sm text-muted-foreground">
|
|
526
|
+
{availableDocs.length} variant{availableDocs.length > 1 ? "s" : ""}
|
|
527
|
+
</span>
|
|
528
|
+
</div>
|
|
529
|
+
|
|
530
|
+
<div className="w-full sm:w-64">
|
|
531
|
+
<Select
|
|
532
|
+
value={isValidSelection ? currentSelection : ""}
|
|
533
|
+
onValueChange={(val) => onSelect(fieldPath, val)}
|
|
534
|
+
>
|
|
535
|
+
<SelectTrigger>
|
|
536
|
+
<SelectValue placeholder="Select a schema variant..." />
|
|
537
|
+
</SelectTrigger>
|
|
538
|
+
<SelectContent>
|
|
539
|
+
{availableDocs.map((doc, i) => (
|
|
540
|
+
<SelectItem key={i} value={doc.variantId || doc.label}>
|
|
541
|
+
{doc.label}
|
|
542
|
+
</SelectItem>
|
|
543
|
+
))}
|
|
544
|
+
</SelectContent>
|
|
545
|
+
</Select>
|
|
546
|
+
</div>
|
|
547
|
+
</div>
|
|
548
|
+
|
|
549
|
+
{selectedDoc ? (
|
|
550
|
+
<div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
|
|
551
|
+
{selectedDoc.description && (
|
|
552
|
+
<div className="text-sm text-muted-foreground">
|
|
553
|
+
<MarkdownBlock>{selectedDoc.description}</MarkdownBlock>
|
|
554
|
+
</div>
|
|
555
|
+
)}
|
|
556
|
+
<div className="bg-muted rounded-md p-3 overflow-x-auto">
|
|
557
|
+
<SchemaPropertyDisplay schema={selectedDoc.specSchema} />
|
|
558
|
+
</div>
|
|
559
|
+
</div>
|
|
560
|
+
) : (
|
|
561
|
+
<div className="text-sm text-muted-foreground italic bg-muted/50 rounded-md p-4 text-center">
|
|
562
|
+
Select a variant from the dropdown above to view its schema.
|
|
563
|
+
</div>
|
|
564
|
+
)}
|
|
565
|
+
</div>
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
383
569
|
// ─── Kind Card ─────────────────────────────────────────────────────────────
|
|
384
570
|
|
|
385
571
|
function KindCard({ kind }: { kind: KindDescription }) {
|
|
386
572
|
const [isOpen, setIsOpen] = useState(false);
|
|
573
|
+
const yamlExample = useMemo(() => generateYamlExample({ kind }), [kind]);
|
|
387
574
|
|
|
388
575
|
return (
|
|
389
576
|
<Card className="mb-3">
|
|
@@ -423,6 +610,12 @@ function KindCard({ kind }: { kind: KindDescription }) {
|
|
|
423
610
|
|
|
424
611
|
{isOpen && (
|
|
425
612
|
<CardContent className="pt-0 space-y-6">
|
|
613
|
+
{/* Entity Envelope Fields */}
|
|
614
|
+
<SchemaBlock
|
|
615
|
+
schema={kind.metadataSchema}
|
|
616
|
+
label="Entity Envelope Fields"
|
|
617
|
+
/>
|
|
618
|
+
|
|
426
619
|
{/* Base Spec Schema */}
|
|
427
620
|
<SchemaBlock schema={kind.specSchema} label="Base Spec Schema" />
|
|
428
621
|
|
|
@@ -451,14 +644,28 @@ function KindCard({ kind }: { kind: KindDescription }) {
|
|
|
451
644
|
</div>
|
|
452
645
|
)}
|
|
453
646
|
|
|
647
|
+
{/* Spec Schema Documentation */}
|
|
648
|
+
{kind.specSchemaDocumentation &&
|
|
649
|
+
kind.specSchemaDocumentation.length > 0 && (
|
|
650
|
+
<SpecSchemaDocumentationSection
|
|
651
|
+
docs={kind.specSchemaDocumentation}
|
|
652
|
+
/>
|
|
653
|
+
)}
|
|
654
|
+
|
|
454
655
|
{/* YAML Example */}
|
|
455
656
|
<div>
|
|
456
657
|
<h4 className="text-sm font-medium mb-2 text-muted-foreground">
|
|
457
658
|
YAML Example
|
|
458
659
|
</h4>
|
|
459
|
-
<
|
|
460
|
-
<
|
|
461
|
-
|
|
660
|
+
<div className="rounded-md overflow-hidden border border-input">
|
|
661
|
+
<CodeEditor
|
|
662
|
+
value={yamlExample}
|
|
663
|
+
language="yaml"
|
|
664
|
+
readOnly
|
|
665
|
+
onChange={() => {}}
|
|
666
|
+
minHeight={`${Math.max(100, yamlExample.split("\n").length * 20 + 20)}px`}
|
|
667
|
+
/>
|
|
668
|
+
</div>
|
|
462
669
|
</div>
|
|
463
670
|
</CardContent>
|
|
464
671
|
)}
|