@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,294 @@
1
+ import { useState } from "react";
2
+ import {
3
+ usePluginClient,
4
+ useApi,
5
+ accessApiRef,
6
+ } from "@checkstack/frontend-api";
7
+ import { GitOpsApi, gitopsAccess } from "@checkstack/gitops-common";
8
+ import {
9
+ Card,
10
+ CardHeader,
11
+ CardHeaderRow,
12
+ CardTitle,
13
+ CardContent,
14
+ Button,
15
+ EmptyState,
16
+ ConfirmationModal,
17
+ Badge,
18
+ useToast,
19
+ } from "@checkstack/ui";
20
+ import {
21
+ Plus,
22
+ RefreshCw,
23
+ Trash2,
24
+ Pencil,
25
+ Github,
26
+ GitlabIcon,
27
+ } from "lucide-react";
28
+ import { extractErrorMessage } from "@checkstack/common";
29
+ import { ProviderEditor } from "./ProviderEditor";
30
+
31
+ const formatInterval = (seconds: number) => {
32
+ if (seconds < 60) return `${seconds}s`;
33
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
34
+ return `${Math.floor(seconds / 3600)}h`;
35
+ };
36
+
37
+ const formatLastSync = (date: Date | null) => {
38
+ if (!date) return "Never";
39
+ return new Date(date).toLocaleString();
40
+ };
41
+
42
+ export const ProviderList = () => {
43
+ const client = usePluginClient(GitOpsApi);
44
+ const accessApi = useApi(accessApiRef);
45
+ const toast = useToast();
46
+
47
+ const { allowed: canManage } = accessApi.useAccess(
48
+ gitopsAccess.provider.manage,
49
+ );
50
+
51
+ const [isEditorOpen, setIsEditorOpen] = useState(false);
52
+ const [editingProvider, setEditingProvider] = useState<{
53
+ id: string;
54
+ type: "github" | "gitlab";
55
+ target: string;
56
+ pathPattern: string;
57
+ baseUrl?: string;
58
+ syncInterval: number;
59
+ deletionPolicy: "orphan" | "auto";
60
+ }>();
61
+ const [confirmModal, setConfirmModal] = useState<{
62
+ isOpen: boolean;
63
+ providerId: string;
64
+ providerTarget: string;
65
+ }>({ isOpen: false, providerId: "", providerTarget: "" });
66
+
67
+ const {
68
+ data: providers,
69
+ isLoading,
70
+ refetch,
71
+ } = client.listProviders.useQuery({});
72
+
73
+ const createMutation = client.createProvider.useMutation({
74
+ onSuccess: () => {
75
+ toast.success("Provider created successfully");
76
+ setIsEditorOpen(false);
77
+ void refetch();
78
+ },
79
+ onError: (error) => {
80
+ toast.error(extractErrorMessage(error, "Failed to create provider"));
81
+ },
82
+ });
83
+
84
+ const updateMutation = client.updateProvider.useMutation({
85
+ onSuccess: () => {
86
+ toast.success("Provider updated successfully");
87
+ setIsEditorOpen(false);
88
+ setEditingProvider(undefined);
89
+ void refetch();
90
+ },
91
+ onError: (error) => {
92
+ toast.error(extractErrorMessage(error, "Failed to update provider"));
93
+ },
94
+ });
95
+
96
+ const deleteMutation = client.deleteProvider.useMutation({
97
+ onSuccess: () => {
98
+ toast.success("Provider deleted successfully");
99
+ setConfirmModal({ isOpen: false, providerId: "", providerTarget: "" });
100
+ void refetch();
101
+ },
102
+ onError: (error) => {
103
+ toast.error(extractErrorMessage(error, "Failed to delete provider"));
104
+ },
105
+ });
106
+
107
+ const syncMutation = client.triggerSync.useMutation({
108
+ onSuccess: () => {
109
+ toast.success("Sync triggered successfully");
110
+ void refetch();
111
+ },
112
+ onError: (error) => {
113
+ toast.error(extractErrorMessage(error, "Failed to trigger sync"));
114
+ },
115
+ });
116
+
117
+ const handleSave = (data: {
118
+ type: "github" | "gitlab";
119
+ target: string;
120
+ pathPattern: string;
121
+ baseUrl?: string;
122
+ authToken?: string;
123
+ syncInterval?: number;
124
+ deletionPolicy?: "orphan" | "auto";
125
+ }) => {
126
+ if (editingProvider) {
127
+ updateMutation.mutate({
128
+ id: editingProvider.id,
129
+ data: {
130
+ target: data.target,
131
+ pathPattern: data.pathPattern,
132
+ baseUrl: data.baseUrl ?? undefined,
133
+ authToken: data.authToken,
134
+ syncInterval: data.syncInterval,
135
+ deletionPolicy: data.deletionPolicy,
136
+ },
137
+ });
138
+ } else {
139
+ createMutation.mutate(data);
140
+ }
141
+ };
142
+
143
+ return (
144
+ <>
145
+ <Card>
146
+ <CardHeader>
147
+ <CardHeaderRow>
148
+ <CardTitle>Git Providers</CardTitle>
149
+ {canManage && (
150
+ <Button size="sm" onClick={() => setIsEditorOpen(true)}>
151
+ <Plus className="w-4 h-4 mr-2" />
152
+ Add Provider
153
+ </Button>
154
+ )}
155
+ </CardHeaderRow>
156
+ </CardHeader>
157
+ <CardContent>
158
+ {isLoading ? (
159
+ <div className="text-center py-8 text-muted-foreground">
160
+ Loading...
161
+ </div>
162
+ ) : !providers || providers.length === 0 ? (
163
+ <EmptyState
164
+ title="No providers configured"
165
+ description="Add a GitHub or GitLab provider to start syncing infrastructure definitions."
166
+ />
167
+ ) : (
168
+ <div className="space-y-3">
169
+ {providers.map((provider) => (
170
+ <div
171
+ key={provider.id}
172
+ className="flex items-center justify-between p-4 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-colors"
173
+ >
174
+ <div className="flex items-center gap-3 min-w-0">
175
+ {provider.type === "github" ? (
176
+ <Github className="w-5 h-5 text-muted-foreground shrink-0" />
177
+ ) : (
178
+ <GitlabIcon className="w-5 h-5 text-muted-foreground shrink-0" />
179
+ )}
180
+ <div className="min-w-0">
181
+ <div className="font-medium truncate">
182
+ {provider.target}
183
+ </div>
184
+ <div className="text-xs text-muted-foreground flex items-center gap-2 mt-0.5">
185
+ <span className="truncate">{provider.pathPattern}</span>
186
+ <span>·</span>
187
+ <span>
188
+ every {formatInterval(provider.syncInterval)}
189
+ </span>
190
+ <span>·</span>
191
+ <Badge
192
+ variant={
193
+ provider.deletionPolicy === "auto"
194
+ ? "destructive"
195
+ : "outline"
196
+ }
197
+ >
198
+ {provider.deletionPolicy}
199
+ </Badge>
200
+ </div>
201
+ </div>
202
+ </div>
203
+
204
+ <div className="flex items-center gap-4 shrink-0">
205
+ <div className="text-right text-xs text-muted-foreground hidden md:block">
206
+ <div>
207
+ Last sync: {formatLastSync(provider.lastSyncAt)}
208
+ </div>
209
+ {provider.lastSyncError && (
210
+ <div className="text-destructive truncate max-w-48">
211
+ {provider.lastSyncError}
212
+ </div>
213
+ )}
214
+ </div>
215
+
216
+ {canManage && (
217
+ <div className="flex items-center gap-1">
218
+ <Button
219
+ variant="ghost"
220
+ size="icon"
221
+ onClick={() =>
222
+ syncMutation.mutate({ providerId: provider.id })
223
+ }
224
+ title="Trigger sync"
225
+ >
226
+ <RefreshCw className="w-4 h-4" />
227
+ </Button>
228
+ <Button
229
+ variant="ghost"
230
+ size="icon"
231
+ onClick={() => {
232
+ setEditingProvider({
233
+ id: provider.id,
234
+ type: provider.type,
235
+ target: provider.target,
236
+ pathPattern: provider.pathPattern,
237
+ baseUrl: provider.baseUrl ?? undefined,
238
+ syncInterval: provider.syncInterval,
239
+ deletionPolicy: provider.deletionPolicy,
240
+ });
241
+ setIsEditorOpen(true);
242
+ }}
243
+ title="Edit provider"
244
+ >
245
+ <Pencil className="w-4 h-4" />
246
+ </Button>
247
+ <Button
248
+ variant="ghost"
249
+ size="icon"
250
+ onClick={() =>
251
+ setConfirmModal({
252
+ isOpen: true,
253
+ providerId: provider.id,
254
+ providerTarget: provider.target,
255
+ })
256
+ }
257
+ title="Delete provider"
258
+ >
259
+ <Trash2 className="w-4 h-4" />
260
+ </Button>
261
+ </div>
262
+ )}
263
+ </div>
264
+ </div>
265
+ ))}
266
+ </div>
267
+ )}
268
+ </CardContent>
269
+ </Card>
270
+
271
+ <ProviderEditor
272
+ open={isEditorOpen}
273
+ onClose={() => {
274
+ setIsEditorOpen(false);
275
+ setEditingProvider(undefined);
276
+ }}
277
+ onSave={handleSave}
278
+ initialData={editingProvider}
279
+ />
280
+
281
+ <ConfirmationModal
282
+ isOpen={confirmModal.isOpen}
283
+ onClose={() =>
284
+ setConfirmModal({ isOpen: false, providerId: "", providerTarget: "" })
285
+ }
286
+ onConfirm={() => deleteMutation.mutate({ id: confirmModal.providerId })}
287
+ title="Delete Provider"
288
+ message={`Are you sure you want to delete the provider for "${confirmModal.providerTarget}"? All provenance tracking will be removed.`}
289
+ confirmText="Delete"
290
+ variant="danger"
291
+ />
292
+ </>
293
+ );
294
+ };
@@ -0,0 +1,110 @@
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
+ } from "@checkstack/ui";
13
+
14
+ interface SecretEditorProps {
15
+ open: boolean;
16
+ onClose: () => void;
17
+ onSave: (data: { name: string; value: string; description?: string }) => void;
18
+ }
19
+
20
+ export const SecretEditor: React.FC<SecretEditorProps> = ({
21
+ open,
22
+ onClose,
23
+ onSave,
24
+ }) => {
25
+ const [name, setName] = useState("");
26
+ const [value, setValue] = useState("");
27
+ const [description, setDescription] = useState("");
28
+
29
+ useEffect(() => {
30
+ if (open) {
31
+ setName("");
32
+ setValue("");
33
+ setDescription("");
34
+ }
35
+ }, [open]);
36
+
37
+ const handleSubmit = (e: React.FormEvent) => {
38
+ e.preventDefault();
39
+ if (!name.trim() || !value.trim()) return;
40
+
41
+ onSave({
42
+ name: name.trim(),
43
+ value: value.trim(),
44
+ description: description.trim() || undefined,
45
+ });
46
+ };
47
+
48
+ return (
49
+ <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
50
+ <DialogContent size="default">
51
+ <form onSubmit={handleSubmit}>
52
+ <DialogHeader>
53
+ <DialogTitle>Create Secret</DialogTitle>
54
+ <DialogDescription className="sr-only">
55
+ Create a new secret for use in GitOps YAML descriptors
56
+ </DialogDescription>
57
+ </DialogHeader>
58
+
59
+ <div className="space-y-4 py-4">
60
+ <div className="space-y-2">
61
+ <Label htmlFor="secret-name">Name</Label>
62
+ <Input
63
+ id="secret-name"
64
+ placeholder="e.g. GITHUB_TOKEN"
65
+ value={name}
66
+ onChange={(e) => setName(e.target.value)}
67
+ className="font-mono"
68
+ required
69
+ />
70
+ <p className="text-xs text-muted-foreground">
71
+ Referenced as <code className="text-xs">{"${{ secrets.NAME }}"}</code> in descriptors.
72
+ </p>
73
+ </div>
74
+
75
+ <div className="space-y-2">
76
+ <Label htmlFor="secret-value">Value</Label>
77
+ <Input
78
+ id="secret-value"
79
+ type="password"
80
+ placeholder="Secret value..."
81
+ value={value}
82
+ onChange={(e) => setValue(e.target.value)}
83
+ required
84
+ />
85
+ </div>
86
+
87
+ <div className="space-y-2">
88
+ <Label htmlFor="secret-description">Description (optional)</Label>
89
+ <Input
90
+ id="secret-description"
91
+ placeholder="What this secret is used for..."
92
+ value={description}
93
+ onChange={(e) => setDescription(e.target.value)}
94
+ />
95
+ </div>
96
+ </div>
97
+
98
+ <DialogFooter>
99
+ <Button type="button" variant="outline" onClick={onClose}>
100
+ Cancel
101
+ </Button>
102
+ <Button type="submit" disabled={!name.trim() || !value.trim()}>
103
+ Create Secret
104
+ </Button>
105
+ </DialogFooter>
106
+ </form>
107
+ </DialogContent>
108
+ </Dialog>
109
+ );
110
+ };
@@ -0,0 +1,204 @@
1
+ import { useState } from "react";
2
+ import {
3
+ usePluginClient,
4
+ useApi,
5
+ accessApiRef,
6
+ } from "@checkstack/frontend-api";
7
+ import { GitOpsApi, gitopsAccess } from "@checkstack/gitops-common";
8
+ import {
9
+ Card,
10
+ CardHeader,
11
+ CardHeaderRow,
12
+ CardTitle,
13
+ CardContent,
14
+ Button,
15
+ EmptyState,
16
+ ConfirmationModal,
17
+ useToast,
18
+ } from "@checkstack/ui";
19
+ import { Plus, RotateCw, Trash2, KeyRound } from "lucide-react";
20
+ import { extractErrorMessage } from "@checkstack/common";
21
+ import { SecretEditor } from "./SecretEditor";
22
+ import { SecretRotateDialog } from "./SecretRotateDialog";
23
+
24
+ const formatDate = (date: Date) => new Date(date).toLocaleString();
25
+
26
+ export const SecretList = () => {
27
+ const client = usePluginClient(GitOpsApi);
28
+ const accessApi = useApi(accessApiRef);
29
+ const toast = useToast();
30
+
31
+ const { allowed: canManage } = accessApi.useAccess(
32
+ gitopsAccess.secret.manage,
33
+ );
34
+
35
+ const [isEditorOpen, setIsEditorOpen] = useState(false);
36
+ const [rotatingSecret, setRotatingSecret] = useState<{
37
+ id: string;
38
+ name: string;
39
+ }>();
40
+ const [confirmModal, setConfirmModal] = useState<{
41
+ isOpen: boolean;
42
+ secretId: string;
43
+ secretName: string;
44
+ }>({ isOpen: false, secretId: "", secretName: "" });
45
+
46
+ const { data: secrets, isLoading, refetch } = client.listSecrets.useQuery({});
47
+
48
+ const createMutation = client.createSecret.useMutation({
49
+ onSuccess: () => {
50
+ toast.success("Secret created successfully");
51
+ setIsEditorOpen(false);
52
+ void refetch();
53
+ },
54
+ onError: (error) => {
55
+ toast.error(extractErrorMessage(error, "Failed to create secret"));
56
+ },
57
+ });
58
+
59
+ const rotateMutation = client.rotateSecret.useMutation({
60
+ onSuccess: () => {
61
+ toast.success("Secret rotated successfully");
62
+ setRotatingSecret(undefined);
63
+ void refetch();
64
+ },
65
+ onError: (error) => {
66
+ toast.error(extractErrorMessage(error, "Failed to rotate secret"));
67
+ },
68
+ });
69
+
70
+ const deleteMutation = client.deleteSecret.useMutation({
71
+ onSuccess: () => {
72
+ toast.success("Secret deleted successfully");
73
+ setConfirmModal({ isOpen: false, secretId: "", secretName: "" });
74
+ void refetch();
75
+ },
76
+ onError: (error) => {
77
+ toast.error(extractErrorMessage(error, "Failed to delete secret"));
78
+ },
79
+ });
80
+
81
+ return (
82
+ <>
83
+ <Card>
84
+ <CardHeader>
85
+ <CardHeaderRow>
86
+ <CardTitle>Secrets</CardTitle>
87
+ {canManage && (
88
+ <Button size="sm" onClick={() => setIsEditorOpen(true)}>
89
+ <Plus className="w-4 h-4 mr-2" />
90
+ Add Secret
91
+ </Button>
92
+ )}
93
+ </CardHeaderRow>
94
+ <p className="text-xs text-muted-foreground mt-1">
95
+ Secrets can be referenced in YAML descriptors using{" "}
96
+ <code className="text-xs">{"${{ secrets.NAME }}"}</code> syntax.
97
+ </p>
98
+ </CardHeader>
99
+ <CardContent>
100
+ {isLoading ? (
101
+ <div className="text-center py-8 text-muted-foreground">
102
+ Loading...
103
+ </div>
104
+ ) : !secrets || secrets.length === 0 ? (
105
+ <EmptyState
106
+ title="No secrets created"
107
+ description="Create secrets to securely reference sensitive values in your YAML descriptors."
108
+ />
109
+ ) : (
110
+ <div className="space-y-3">
111
+ {secrets.map((secret) => (
112
+ <div
113
+ key={secret.id}
114
+ className="flex items-center justify-between p-4 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-colors"
115
+ >
116
+ <div className="flex items-center gap-3 min-w-0">
117
+ <KeyRound className="w-4 h-4 text-muted-foreground shrink-0" />
118
+ <div className="min-w-0">
119
+ <div className="font-medium font-mono text-sm">
120
+ {secret.name}
121
+ </div>
122
+ {secret.description && (
123
+ <div className="text-xs text-muted-foreground mt-0.5 truncate">
124
+ {secret.description}
125
+ </div>
126
+ )}
127
+ </div>
128
+ </div>
129
+
130
+ <div className="flex items-center gap-4 shrink-0">
131
+ <div className="text-right text-xs text-muted-foreground hidden md:block">
132
+ <div>Updated: {formatDate(secret.updatedAt)}</div>
133
+ </div>
134
+
135
+ {canManage && (
136
+ <div className="flex items-center gap-1">
137
+ <Button
138
+ variant="ghost"
139
+ size="icon"
140
+ onClick={() =>
141
+ setRotatingSecret({
142
+ id: secret.id,
143
+ name: secret.name,
144
+ })
145
+ }
146
+ title="Rotate secret"
147
+ >
148
+ <RotateCw className="w-4 h-4" />
149
+ </Button>
150
+ <Button
151
+ variant="ghost"
152
+ size="icon"
153
+ onClick={() =>
154
+ setConfirmModal({
155
+ isOpen: true,
156
+ secretId: secret.id,
157
+ secretName: secret.name,
158
+ })
159
+ }
160
+ title="Delete secret"
161
+ >
162
+ <Trash2 className="w-4 h-4" />
163
+ </Button>
164
+ </div>
165
+ )}
166
+ </div>
167
+ </div>
168
+ ))}
169
+ </div>
170
+ )}
171
+ </CardContent>
172
+ </Card>
173
+
174
+ <SecretEditor
175
+ open={isEditorOpen}
176
+ onClose={() => setIsEditorOpen(false)}
177
+ onSave={(data) => createMutation.mutate(data)}
178
+ />
179
+
180
+ <SecretRotateDialog
181
+ open={!!rotatingSecret}
182
+ secretName={rotatingSecret?.name ?? ""}
183
+ onClose={() => setRotatingSecret(undefined)}
184
+ onSave={(value) => {
185
+ if (rotatingSecret) {
186
+ rotateMutation.mutate({ id: rotatingSecret.id, value });
187
+ }
188
+ }}
189
+ />
190
+
191
+ <ConfirmationModal
192
+ isOpen={confirmModal.isOpen}
193
+ onClose={() =>
194
+ setConfirmModal({ isOpen: false, secretId: "", secretName: "" })
195
+ }
196
+ onConfirm={() => deleteMutation.mutate({ id: confirmModal.secretId })}
197
+ title="Delete Secret"
198
+ message={`Are you sure you want to delete the secret "${confirmModal.secretName}"? Any descriptors referencing this secret will fail validation.`}
199
+ confirmText="Delete"
200
+ variant="danger"
201
+ />
202
+ </>
203
+ );
204
+ };
@@ -0,0 +1,79 @@
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
+ } from "@checkstack/ui";
13
+
14
+ interface SecretRotateDialogProps {
15
+ open: boolean;
16
+ secretName: string;
17
+ onClose: () => void;
18
+ onSave: (value: string) => void;
19
+ }
20
+
21
+ export const SecretRotateDialog: React.FC<SecretRotateDialogProps> = ({
22
+ open,
23
+ secretName,
24
+ onClose,
25
+ onSave,
26
+ }) => {
27
+ const [value, setValue] = useState("");
28
+
29
+ useEffect(() => {
30
+ if (open) {
31
+ setValue("");
32
+ }
33
+ }, [open]);
34
+
35
+ const handleSubmit = (e: React.FormEvent) => {
36
+ e.preventDefault();
37
+ if (!value.trim()) return;
38
+ onSave(value.trim());
39
+ };
40
+
41
+ return (
42
+ <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
43
+ <DialogContent size="default">
44
+ <form onSubmit={handleSubmit}>
45
+ <DialogHeader>
46
+ <DialogTitle>Rotate Secret</DialogTitle>
47
+ <DialogDescription>
48
+ Enter a new value for <code className="font-mono text-sm">{secretName}</code>.
49
+ The old value will be permanently replaced.
50
+ </DialogDescription>
51
+ </DialogHeader>
52
+
53
+ <div className="space-y-4 py-4">
54
+ <div className="space-y-2">
55
+ <Label htmlFor="rotate-value">New Value</Label>
56
+ <Input
57
+ id="rotate-value"
58
+ type="password"
59
+ placeholder="New secret value..."
60
+ value={value}
61
+ onChange={(e) => setValue(e.target.value)}
62
+ required
63
+ />
64
+ </div>
65
+ </div>
66
+
67
+ <DialogFooter>
68
+ <Button type="button" variant="outline" onClick={onClose}>
69
+ Cancel
70
+ </Button>
71
+ <Button type="submit" disabled={!value.trim()}>
72
+ Rotate Secret
73
+ </Button>
74
+ </DialogFooter>
75
+ </form>
76
+ </DialogContent>
77
+ </Dialog>
78
+ );
79
+ };