@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 ADDED
@@ -0,0 +1,37 @@
1
+ # @checkstack/gitops-frontend
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6c40b5b: Generalized provenance system and GitOps frontend plugin
8
+
9
+ **Breaking**: `EntityKindDefinition.reconcile()` now returns `{ entityId: string }` instead of `void`. Plugins must return the plugin-specific entity ID (e.g., catalog system UUID) so the engine can store it in provenance.
10
+
11
+ - Added `entityId` column to the provenance table (non-nullable)
12
+ - Reconciler engine passes `existingEntityId` to plugins for updates
13
+ - `getProvenance` now supports lookup by `entityId` in addition to `entityName`
14
+ - Added provider CRUD endpoints: `createProvider`, `updateProvider`, `deleteProvider`
15
+ - Created `gitops-frontend` plugin with provider management, secret management, and sync status dashboard
16
+ - Removed `gitops_entity_name` metadata markers from catalog entities
17
+ - Removed `findSystemByGitOpsName`, `deleteSystemByGitOpsName` (and Group equivalents) from EntityService
18
+ - Added provenance-based UI locking in catalog-frontend: edit/delete/drag disabled for GitOps-managed systems and groups
19
+
20
+ - 6c40b5b: Add Kind Registry browser and developer documentation
21
+
22
+ - Added `gitopsAccess.kinds.read` access rule for standalone Kind Registry access
23
+ - Added `describeKinds()` method to the internal entity kind registry, serializing Zod schemas to JSON Schema
24
+ - Added `listKinds` RPC endpoint gated by the new access rule
25
+ - Created standalone Kind Registry page with schema visualization, extension listing, and auto-generated YAML examples
26
+ - Added Kind Registry link to the user menu
27
+ - Created developer documentation for entity kind and extension registration in `docs/backend/gitops-entity-kinds.md`
28
+
29
+ ### Patch Changes
30
+
31
+ - Updated dependencies [6c40b5b]
32
+ - Updated dependencies [6c40b5b]
33
+ - Updated dependencies [6c40b5b]
34
+ - Updated dependencies [6c40b5b]
35
+ - Updated dependencies [4b0934d]
36
+ - @checkstack/gitops-common@0.1.0
37
+ - @checkstack/ui@1.3.6
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@checkstack/gitops-frontend",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "src/index.tsx",
6
+ "checkstack": {
7
+ "type": "frontend"
8
+ },
9
+ "scripts": {
10
+ "typecheck": "tsc --noEmit",
11
+ "lint": "bun run lint:code",
12
+ "lint:code": "eslint . --max-warnings 0"
13
+ },
14
+ "dependencies": {
15
+ "@checkstack/common": "0.6.5",
16
+ "@checkstack/frontend-api": "0.3.9",
17
+ "@checkstack/gitops-common": "0.0.1",
18
+ "@checkstack/ui": "1.3.5",
19
+ "lucide-react": "^0.344.0",
20
+ "react": "^18.2.0",
21
+ "react-router-dom": "^6.22.0"
22
+ },
23
+ "devDependencies": {
24
+ "typescript": "^5.0.0",
25
+ "@types/react": "^18.2.0",
26
+ "@checkstack/tsconfig": "0.0.5",
27
+ "@checkstack/scripts": "0.1.2"
28
+ }
29
+ }
@@ -0,0 +1,35 @@
1
+ import React from "react";
2
+ import { GitBranch, ExternalLink } from "lucide-react";
3
+ import type { Provenance } from "@checkstack/gitops-common";
4
+
5
+ interface GitOpsLockBannerProps {
6
+ provenance: Provenance;
7
+ }
8
+
9
+ /**
10
+ * Banner displayed on entity detail pages when the entity is managed by GitOps.
11
+ * Informs the user that manual edits are disabled and shows the Git source.
12
+ */
13
+ export const GitOpsLockBanner: React.FC<GitOpsLockBannerProps> = ({
14
+ provenance,
15
+ }) => {
16
+ return (
17
+ <div className="flex items-center gap-3 p-3 rounded-lg border border-primary/20 bg-primary/5 text-sm">
18
+ <GitBranch className="w-5 h-5 text-primary shrink-0" />
19
+ <div className="min-w-0 flex-1">
20
+ <span className="font-medium text-foreground">
21
+ Managed by GitOps
22
+ </span>
23
+ <span className="text-muted-foreground ml-1">
24
+ — edits are disabled. Changes must be made in Git.
25
+ </span>
26
+ <div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
27
+ <span className="truncate">
28
+ {provenance.repository}/{provenance.filePath}
29
+ </span>
30
+ <ExternalLink className="w-3 h-3 shrink-0" />
31
+ </div>
32
+ </div>
33
+ </div>
34
+ );
35
+ };
@@ -0,0 +1,28 @@
1
+ import { useNavigate } from "react-router-dom";
2
+ import { GitBranch } from "lucide-react";
3
+ import { DropdownMenuItem } from "@checkstack/ui";
4
+ import type { UserMenuItemsContext } from "@checkstack/frontend-api";
5
+ import { resolveRoute } from "@checkstack/common";
6
+ import { pluginMetadata, gitopsAccess, gitopsRoutes } from "@checkstack/gitops-common";
7
+ import React from "react";
8
+
9
+ const REQUIRED_ACCESS_RULE = `${pluginMetadata.pluginId}.${gitopsAccess.provider.read.id}`;
10
+
11
+ export function GitOpsMenuItem({
12
+ accessRules: userPerms,
13
+ }: UserMenuItemsContext) {
14
+ const navigate = useNavigate();
15
+ const canView =
16
+ userPerms.includes("*") || userPerms.includes(REQUIRED_ACCESS_RULE);
17
+
18
+ if (!canView) return <React.Fragment />;
19
+
20
+ return (
21
+ <DropdownMenuItem
22
+ onClick={() => navigate(resolveRoute(gitopsRoutes.routes.home))}
23
+ icon={<GitBranch className="h-4 w-4" />}
24
+ >
25
+ GitOps
26
+ </DropdownMenuItem>
27
+ );
28
+ }
@@ -0,0 +1,28 @@
1
+ import { useNavigate } from "react-router-dom";
2
+ import { Blocks } from "lucide-react";
3
+ import { DropdownMenuItem } from "@checkstack/ui";
4
+ import type { UserMenuItemsContext } from "@checkstack/frontend-api";
5
+ import { resolveRoute } from "@checkstack/common";
6
+ import { pluginMetadata, gitopsAccess, gitopsRoutes } from "@checkstack/gitops-common";
7
+ import React from "react";
8
+
9
+ const REQUIRED_ACCESS_RULE = `${pluginMetadata.pluginId}.${gitopsAccess.kinds.read.id}`;
10
+
11
+ export function KindRegistryMenuItem({
12
+ accessRules: userPerms,
13
+ }: UserMenuItemsContext) {
14
+ const navigate = useNavigate();
15
+ const canView =
16
+ userPerms.includes("*") || userPerms.includes(REQUIRED_ACCESS_RULE);
17
+
18
+ if (!canView) return <React.Fragment />;
19
+
20
+ return (
21
+ <DropdownMenuItem
22
+ onClick={() => navigate(resolveRoute(gitopsRoutes.routes.kinds))}
23
+ icon={<Blocks className="h-4 w-4" />}
24
+ >
25
+ Kind Registry
26
+ </DropdownMenuItem>
27
+ );
28
+ }
@@ -0,0 +1,246 @@
1
+ import { usePluginClient, useApi, accessApiRef } from "@checkstack/frontend-api";
2
+ import { GitOpsApi, gitopsAccess } from "@checkstack/gitops-common";
3
+ import type { Provenance } from "@checkstack/gitops-common";
4
+ import {
5
+ Card,
6
+ CardHeader,
7
+ CardHeaderRow,
8
+ CardTitle,
9
+ CardContent,
10
+ Button,
11
+ Badge,
12
+ EmptyState,
13
+ ConfirmationModal,
14
+ useToast,
15
+ } from "@checkstack/ui";
16
+ import { useState } from "react";
17
+ import { CheckCircle, AlertTriangle, XCircle, Trash2, X } from "lucide-react";
18
+ import { extractErrorMessage } from "@checkstack/common";
19
+
20
+ export const ProvenanceStatus = () => {
21
+ const client = usePluginClient(GitOpsApi);
22
+ const accessApi = useApi(accessApiRef);
23
+ const toast = useToast();
24
+
25
+ const { allowed: canManage } = accessApi.useAccess(gitopsAccess.provider.manage);
26
+
27
+ const [confirmModal, setConfirmModal] = useState<{
28
+ isOpen: boolean;
29
+ provenanceId: string;
30
+ entityName: string;
31
+ }>({ isOpen: false, provenanceId: "", entityName: "" });
32
+
33
+ const {
34
+ data: provenanceEntries,
35
+ isLoading,
36
+ refetch,
37
+ } = client.listProvenance.useQuery({});
38
+
39
+ const confirmDeleteMutation = client.confirmOrphanDeletion.useMutation({
40
+ onSuccess: () => {
41
+ toast.success("Orphan deleted successfully");
42
+ setConfirmModal({ isOpen: false, provenanceId: "", entityName: "" });
43
+ void refetch();
44
+ },
45
+ onError: (error) => {
46
+ toast.error(extractErrorMessage(error, "Failed to delete orphan"));
47
+ },
48
+ });
49
+
50
+ const dismissMutation = client.dismissOrphan.useMutation({
51
+ onSuccess: () => {
52
+ toast.success("Orphan dismissed — entity is no longer tracked by GitOps");
53
+ void refetch();
54
+ },
55
+ onError: (error) => {
56
+ toast.error(extractErrorMessage(error, "Failed to dismiss orphan"));
57
+ },
58
+ });
59
+
60
+ const entries = provenanceEntries ?? [];
61
+ const synced = entries.filter((e) => e.status === "synced");
62
+ const errors = entries.filter((e) => e.status === "error");
63
+ const orphaned = entries.filter((e) => e.status === "orphaned");
64
+
65
+ const statusIcon = (status: Provenance["status"]) => {
66
+ switch (status) {
67
+ case "synced": {
68
+ return <CheckCircle className="w-4 h-4 text-emerald-500" />;
69
+ }
70
+ case "error": {
71
+ return <XCircle className="w-4 h-4 text-destructive" />;
72
+ }
73
+ case "orphaned": {
74
+ return <AlertTriangle className="w-4 h-4 text-amber-500" />;
75
+ }
76
+ }
77
+ };
78
+
79
+ const statusBadge = (status: Provenance["status"]) => {
80
+ const variants: Record<Provenance["status"], "default" | "destructive" | "outline"> = {
81
+ synced: "default",
82
+ error: "destructive",
83
+ orphaned: "outline",
84
+ };
85
+ return <Badge variant={variants[status]}>{status}</Badge>;
86
+ };
87
+
88
+ const renderEntryList = (list: Provenance[], showOrphanActions: boolean) => (
89
+ <div className="space-y-2">
90
+ {list.map((entry) => (
91
+ <div
92
+ key={entry.id}
93
+ className="flex items-center justify-between p-3 rounded-lg border border-border bg-background/50"
94
+ >
95
+ <div className="flex items-center gap-3 min-w-0">
96
+ {statusIcon(entry.status)}
97
+ <div className="min-w-0">
98
+ <div className="text-sm font-medium">
99
+ <span className="text-muted-foreground">{entry.kind}/</span>
100
+ {entry.entityName}
101
+ </div>
102
+ <div className="text-xs text-muted-foreground mt-0.5 truncate">
103
+ {entry.repository}/{entry.filePath}
104
+ </div>
105
+ {entry.errorMessage && (
106
+ <div className="text-xs text-destructive mt-0.5 truncate">
107
+ {entry.errorMessage}
108
+ </div>
109
+ )}
110
+ </div>
111
+ </div>
112
+
113
+ <div className="flex items-center gap-2 shrink-0">
114
+ {statusBadge(entry.status)}
115
+ {showOrphanActions && canManage && (
116
+ <>
117
+ <Button
118
+ variant="ghost"
119
+ size="icon"
120
+ onClick={() =>
121
+ setConfirmModal({
122
+ isOpen: true,
123
+ provenanceId: entry.id,
124
+ entityName: `${entry.kind}/${entry.entityName}`,
125
+ })
126
+ }
127
+ title="Confirm deletion"
128
+ >
129
+ <Trash2 className="w-4 h-4" />
130
+ </Button>
131
+ <Button
132
+ variant="ghost"
133
+ size="icon"
134
+ onClick={() => dismissMutation.mutate({ provenanceId: entry.id })}
135
+ title="Dismiss orphan"
136
+ >
137
+ <X className="w-4 h-4" />
138
+ </Button>
139
+ </>
140
+ )}
141
+ </div>
142
+ </div>
143
+ ))}
144
+ </div>
145
+ );
146
+
147
+ return (
148
+ <>
149
+ <div className="space-y-6">
150
+ {/* Summary */}
151
+ <div className="grid grid-cols-3 gap-4">
152
+ <Card>
153
+ <CardContent className="pt-6">
154
+ <div className="flex items-center gap-2">
155
+ <CheckCircle className="w-5 h-5 text-emerald-500" />
156
+ <span className="text-2xl font-bold">{synced.length}</span>
157
+ <span className="text-sm text-muted-foreground">Synced</span>
158
+ </div>
159
+ </CardContent>
160
+ </Card>
161
+ <Card>
162
+ <CardContent className="pt-6">
163
+ <div className="flex items-center gap-2">
164
+ <XCircle className="w-5 h-5 text-destructive" />
165
+ <span className="text-2xl font-bold">{errors.length}</span>
166
+ <span className="text-sm text-muted-foreground">Errors</span>
167
+ </div>
168
+ </CardContent>
169
+ </Card>
170
+ <Card>
171
+ <CardContent className="pt-6">
172
+ <div className="flex items-center gap-2">
173
+ <AlertTriangle className="w-5 h-5 text-amber-500" />
174
+ <span className="text-2xl font-bold">{orphaned.length}</span>
175
+ <span className="text-sm text-muted-foreground">Orphaned</span>
176
+ </div>
177
+ </CardContent>
178
+ </Card>
179
+ </div>
180
+
181
+ {/* Orphaned entities — shown first if any */}
182
+ {orphaned.length > 0 && (
183
+ <Card>
184
+ <CardHeader>
185
+ <CardHeaderRow>
186
+ <CardTitle className="flex items-center gap-2">
187
+ <AlertTriangle className="w-5 h-5 text-amber-500" />
188
+ Orphaned Entities
189
+ </CardTitle>
190
+ </CardHeaderRow>
191
+ <p className="text-xs text-muted-foreground mt-1">
192
+ These entities were removed from Git. Confirm deletion or dismiss to keep the entity.
193
+ </p>
194
+ </CardHeader>
195
+ <CardContent>{renderEntryList(orphaned, true)}</CardContent>
196
+ </Card>
197
+ )}
198
+
199
+ {/* Errors */}
200
+ {errors.length > 0 && (
201
+ <Card>
202
+ <CardHeader>
203
+ <CardTitle className="flex items-center gap-2">
204
+ <XCircle className="w-5 h-5 text-destructive" />
205
+ Sync Errors
206
+ </CardTitle>
207
+ </CardHeader>
208
+ <CardContent>{renderEntryList(errors, false)}</CardContent>
209
+ </Card>
210
+ )}
211
+
212
+ {/* Synced entities */}
213
+ <Card>
214
+ <CardHeader>
215
+ <CardTitle className="flex items-center gap-2">
216
+ <CheckCircle className="w-5 h-5 text-emerald-500" />
217
+ Synced Entities
218
+ </CardTitle>
219
+ </CardHeader>
220
+ <CardContent>
221
+ {isLoading ? (
222
+ <div className="text-center py-8 text-muted-foreground">Loading...</div>
223
+ ) : synced.length === 0 ? (
224
+ <EmptyState
225
+ title="No synced entities"
226
+ description="Entities will appear here after a successful sync."
227
+ />
228
+ ) : (
229
+ renderEntryList(synced, false)
230
+ )}
231
+ </CardContent>
232
+ </Card>
233
+ </div>
234
+
235
+ <ConfirmationModal
236
+ isOpen={confirmModal.isOpen}
237
+ onClose={() => setConfirmModal({ isOpen: false, provenanceId: "", entityName: "" })}
238
+ onConfirm={() => confirmDeleteMutation.mutate({ provenanceId: confirmModal.provenanceId })}
239
+ title="Confirm Orphan Deletion"
240
+ message={`Are you sure you want to permanently delete "${confirmModal.entityName}"? This will remove the entity from the system.`}
241
+ confirmText="Delete"
242
+ variant="danger"
243
+ />
244
+ </>
245
+ );
246
+ };
@@ -0,0 +1,214 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import {
3
+ Button,
4
+ Input,
5
+ Label,
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ Dialog,
12
+ DialogContent,
13
+ DialogDescription,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ DialogFooter,
17
+ } from "@checkstack/ui";
18
+
19
+ interface ProviderEditorProps {
20
+ open: boolean;
21
+ onClose: () => void;
22
+ onSave: (data: {
23
+ type: "github" | "gitlab";
24
+ target: string;
25
+ pathPattern: string;
26
+ baseUrl?: string;
27
+ authToken?: string;
28
+ syncInterval?: number;
29
+ deletionPolicy?: "orphan" | "auto";
30
+ }) => void;
31
+ initialData?: {
32
+ id: string;
33
+ type: "github" | "gitlab";
34
+ target: string;
35
+ pathPattern: string;
36
+ baseUrl?: string;
37
+ syncInterval: number;
38
+ deletionPolicy: "orphan" | "auto";
39
+ };
40
+ }
41
+
42
+ export const ProviderEditor: React.FC<ProviderEditorProps> = ({
43
+ open,
44
+ onClose,
45
+ onSave,
46
+ initialData,
47
+ }) => {
48
+ const [type, setType] = useState<"github" | "gitlab">(initialData?.type ?? "github");
49
+ const [target, setTarget] = useState(initialData?.target ?? "");
50
+ const [pathPattern, setPathPattern] = useState(initialData?.pathPattern ?? ".checkstack/**/*.yaml");
51
+ const [baseUrl, setBaseUrl] = useState(initialData?.baseUrl ?? "");
52
+ const [authToken, setAuthToken] = useState("");
53
+ const [syncInterval, setSyncInterval] = useState(String(initialData?.syncInterval ?? 300));
54
+ const [deletionPolicy, setDeletionPolicy] = useState<"orphan" | "auto">(
55
+ initialData?.deletionPolicy ?? "orphan",
56
+ );
57
+
58
+ useEffect(() => {
59
+ if (open) {
60
+ setType(initialData?.type ?? "github");
61
+ setTarget(initialData?.target ?? "");
62
+ setPathPattern(initialData?.pathPattern ?? ".checkstack/**/*.yaml");
63
+ setBaseUrl(initialData?.baseUrl ?? "");
64
+ setAuthToken("");
65
+ setSyncInterval(String(initialData?.syncInterval ?? 300));
66
+ setDeletionPolicy(initialData?.deletionPolicy ?? "orphan");
67
+ }
68
+ }, [open, initialData]);
69
+
70
+ const handleSubmit = (e: React.FormEvent) => {
71
+ e.preventDefault();
72
+ if (!target.trim() || !pathPattern.trim()) return;
73
+
74
+ onSave({
75
+ type,
76
+ target: target.trim(),
77
+ pathPattern: pathPattern.trim(),
78
+ baseUrl: baseUrl.trim() || undefined,
79
+ authToken: authToken.trim() || undefined,
80
+ syncInterval: Number(syncInterval) || 300,
81
+ deletionPolicy,
82
+ });
83
+ };
84
+
85
+ return (
86
+ <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
87
+ <DialogContent size="default">
88
+ <form onSubmit={handleSubmit}>
89
+ <DialogHeader>
90
+ <DialogTitle>
91
+ {initialData ? "Edit Provider" : "Add Provider"}
92
+ </DialogTitle>
93
+ <DialogDescription className="sr-only">
94
+ {initialData
95
+ ? "Modify the settings for this Git provider"
96
+ : "Configure a new Git provider for GitOps syncing"}
97
+ </DialogDescription>
98
+ </DialogHeader>
99
+
100
+ <div className="space-y-4 py-4">
101
+ <div className="space-y-2">
102
+ <Label htmlFor="provider-type">Provider Type</Label>
103
+ <Select
104
+ value={type}
105
+ onValueChange={(v) => setType(v as "github" | "gitlab")}
106
+ disabled={!!initialData}
107
+ >
108
+ <SelectTrigger id="provider-type">
109
+ <SelectValue />
110
+ </SelectTrigger>
111
+ <SelectContent>
112
+ <SelectItem value="github">GitHub</SelectItem>
113
+ <SelectItem value="gitlab">GitLab</SelectItem>
114
+ </SelectContent>
115
+ </Select>
116
+ </div>
117
+
118
+ <div className="space-y-2">
119
+ <Label htmlFor="provider-target">Target</Label>
120
+ <Input
121
+ id="provider-target"
122
+ placeholder="e.g. my-org or my-org/my-repo"
123
+ value={target}
124
+ onChange={(e) => setTarget(e.target.value)}
125
+ required
126
+ />
127
+ <p className="text-xs text-muted-foreground">
128
+ Organization name for org-wide scanning, or owner/repo for a single repository.
129
+ </p>
130
+ </div>
131
+
132
+ <div className="space-y-2">
133
+ <Label htmlFor="provider-path">Path Pattern</Label>
134
+ <Input
135
+ id="provider-path"
136
+ placeholder=".checkstack/**/*.yaml"
137
+ value={pathPattern}
138
+ onChange={(e) => setPathPattern(e.target.value)}
139
+ required
140
+ />
141
+ <p className="text-xs text-muted-foreground">
142
+ Glob pattern for matching descriptor files in repositories.
143
+ </p>
144
+ </div>
145
+
146
+ <div className="space-y-2">
147
+ <Label htmlFor="provider-base-url">Base URL (optional)</Label>
148
+ <Input
149
+ id="provider-base-url"
150
+ placeholder="https://github.example.com/api/v3"
151
+ value={baseUrl}
152
+ onChange={(e) => setBaseUrl(e.target.value)}
153
+ />
154
+ <p className="text-xs text-muted-foreground">
155
+ For GitHub Enterprise or self-hosted GitLab instances.
156
+ </p>
157
+ </div>
158
+
159
+ <div className="space-y-2">
160
+ <Label htmlFor="provider-auth-token">
161
+ Auth Token {initialData ? "(leave empty to keep current)" : "(optional)"}
162
+ </Label>
163
+ <Input
164
+ id="provider-auth-token"
165
+ type="password"
166
+ placeholder="ghp_xxxx..."
167
+ value={authToken}
168
+ onChange={(e) => setAuthToken(e.target.value)}
169
+ />
170
+ </div>
171
+
172
+ <div className="grid grid-cols-2 gap-4">
173
+ <div className="space-y-2">
174
+ <Label htmlFor="provider-interval">Sync Interval (seconds)</Label>
175
+ <Input
176
+ id="provider-interval"
177
+ type="number"
178
+ min={60}
179
+ value={syncInterval}
180
+ onChange={(e) => setSyncInterval(e.target.value)}
181
+ />
182
+ </div>
183
+
184
+ <div className="space-y-2">
185
+ <Label htmlFor="provider-deletion">Deletion Policy</Label>
186
+ <Select
187
+ value={deletionPolicy}
188
+ onValueChange={(v) => setDeletionPolicy(v as "orphan" | "auto")}
189
+ >
190
+ <SelectTrigger id="provider-deletion">
191
+ <SelectValue />
192
+ </SelectTrigger>
193
+ <SelectContent>
194
+ <SelectItem value="orphan">Orphan (manual review)</SelectItem>
195
+ <SelectItem value="auto">Auto-delete</SelectItem>
196
+ </SelectContent>
197
+ </Select>
198
+ </div>
199
+ </div>
200
+ </div>
201
+
202
+ <DialogFooter>
203
+ <Button type="button" variant="outline" onClick={onClose}>
204
+ Cancel
205
+ </Button>
206
+ <Button type="submit" disabled={!target.trim() || !pathPattern.trim()}>
207
+ {initialData ? "Save Changes" : "Add Provider"}
208
+ </Button>
209
+ </DialogFooter>
210
+ </form>
211
+ </DialogContent>
212
+ </Dialog>
213
+ );
214
+ };