@clef-sh/ui 0.1.15-beta.97 → 0.1.15
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/dist/client/assets/index-C4tsbWst.js +26 -0
- package/dist/client/index.html +1 -1
- package/dist/client-lib/components/MatrixGrid.d.ts +3 -1
- package/dist/client-lib/components/MatrixGrid.d.ts.map +1 -1
- package/dist/client-lib/components/SyncPanel.d.ts +8 -0
- package/dist/client-lib/components/SyncPanel.d.ts.map +1 -0
- package/dist/server/api.d.ts.map +1 -1
- package/dist/server/api.js +83 -4
- package/dist/server/api.js.map +1 -1
- package/package.json +1 -1
- package/src/client/App.tsx +2 -16
- package/src/client/components/MatrixGrid.tsx +179 -145
- package/src/client/components/SyncPanel.test.tsx +138 -0
- package/src/client/components/SyncPanel.tsx +217 -0
- package/src/client/screens/BackendScreen.tsx +0 -1
- package/src/client/screens/MatrixView.tsx +24 -2
- package/src/client/screens/NamespaceEditor.test.tsx +24 -24
- package/src/client/screens/NamespaceEditor.tsx +28 -62
- package/dist/client/assets/index-rBYybJbt.js +0 -26
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { theme } from "../theme";
|
|
3
|
+
import { apiFetch } from "../api";
|
|
4
|
+
import { Button } from "./Button";
|
|
5
|
+
import { EnvBadge } from "./EnvBadge";
|
|
6
|
+
|
|
7
|
+
interface SyncCellPlan {
|
|
8
|
+
namespace: string;
|
|
9
|
+
environment: string;
|
|
10
|
+
missingKeys: string[];
|
|
11
|
+
isProtected: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface SyncPlan {
|
|
15
|
+
cells: SyncCellPlan[];
|
|
16
|
+
totalKeys: number;
|
|
17
|
+
hasProtectedEnvs: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface SyncResult {
|
|
21
|
+
modifiedCells: string[];
|
|
22
|
+
scaffoldedKeys: Record<string, string[]>;
|
|
23
|
+
totalKeysScaffolded: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SyncPanelProps {
|
|
27
|
+
namespace: string;
|
|
28
|
+
onComplete: () => void;
|
|
29
|
+
onCancel: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function SyncPanel({ namespace, onComplete, onCancel }: SyncPanelProps) {
|
|
33
|
+
const [phase, setPhase] = useState<"loading" | "preview" | "syncing" | "done" | "error">(
|
|
34
|
+
"loading",
|
|
35
|
+
);
|
|
36
|
+
const [plan, setPlan] = useState<SyncPlan | null>(null);
|
|
37
|
+
const [result, setResult] = useState<SyncResult | null>(null);
|
|
38
|
+
const [error, setError] = useState<string | null>(null);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
let cancelled = false;
|
|
42
|
+
(async () => {
|
|
43
|
+
try {
|
|
44
|
+
const res = await apiFetch("/api/sync/preview", {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: { "Content-Type": "application/json" },
|
|
47
|
+
body: JSON.stringify({ namespace }),
|
|
48
|
+
});
|
|
49
|
+
if (cancelled) return;
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
const data = await res.json();
|
|
52
|
+
setError(data.error || "Preview failed");
|
|
53
|
+
setPhase("error");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const data = (await res.json()) as SyncPlan;
|
|
57
|
+
setPlan(data);
|
|
58
|
+
setPhase("preview");
|
|
59
|
+
} catch {
|
|
60
|
+
if (!cancelled) {
|
|
61
|
+
setError("Failed to load sync preview");
|
|
62
|
+
setPhase("error");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
})();
|
|
66
|
+
return () => {
|
|
67
|
+
cancelled = true;
|
|
68
|
+
};
|
|
69
|
+
}, [namespace]);
|
|
70
|
+
|
|
71
|
+
const handleSync = async () => {
|
|
72
|
+
setPhase("syncing");
|
|
73
|
+
try {
|
|
74
|
+
const res = await apiFetch("/api/sync", {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "Content-Type": "application/json" },
|
|
77
|
+
body: JSON.stringify({ namespace }),
|
|
78
|
+
});
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
const data = await res.json();
|
|
81
|
+
setError(data.error || "Sync failed");
|
|
82
|
+
setPhase("error");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const data = await res.json();
|
|
86
|
+
setResult(data.result as SyncResult);
|
|
87
|
+
setPhase("done");
|
|
88
|
+
setTimeout(onComplete, 1500);
|
|
89
|
+
} catch {
|
|
90
|
+
setError("Sync failed");
|
|
91
|
+
setPhase("error");
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div
|
|
97
|
+
data-testid="sync-panel"
|
|
98
|
+
style={{
|
|
99
|
+
background: theme.surface,
|
|
100
|
+
border: `1px solid ${theme.border}`,
|
|
101
|
+
borderRadius: 8,
|
|
102
|
+
padding: "16px 20px",
|
|
103
|
+
marginTop: 8,
|
|
104
|
+
marginBottom: 8,
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{phase === "loading" && (
|
|
108
|
+
<div style={{ fontFamily: theme.sans, fontSize: 13, color: theme.textMuted }}>
|
|
109
|
+
Loading sync preview...
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{phase === "preview" && plan && (
|
|
114
|
+
<>
|
|
115
|
+
{plan.totalKeys === 0 ? (
|
|
116
|
+
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
117
|
+
<span
|
|
118
|
+
data-testid="sync-in-sync"
|
|
119
|
+
style={{ fontFamily: theme.sans, fontSize: 13, color: theme.green }}
|
|
120
|
+
>
|
|
121
|
+
All environments in sync
|
|
122
|
+
</span>
|
|
123
|
+
<Button onClick={onCancel}>Close</Button>
|
|
124
|
+
</div>
|
|
125
|
+
) : (
|
|
126
|
+
<>
|
|
127
|
+
<div
|
|
128
|
+
style={{
|
|
129
|
+
fontFamily: theme.sans,
|
|
130
|
+
fontSize: 13,
|
|
131
|
+
fontWeight: 600,
|
|
132
|
+
color: theme.text,
|
|
133
|
+
marginBottom: 10,
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
Sync {namespace} — {plan.totalKeys} key{plan.totalKeys !== 1 ? "s" : ""} to scaffold
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{plan.hasProtectedEnvs && (
|
|
140
|
+
<div
|
|
141
|
+
style={{
|
|
142
|
+
fontFamily: theme.sans,
|
|
143
|
+
fontSize: 12,
|
|
144
|
+
color: theme.yellow,
|
|
145
|
+
background: theme.yellowDim,
|
|
146
|
+
border: `1px solid ${theme.yellow}33`,
|
|
147
|
+
borderRadius: 5,
|
|
148
|
+
padding: "6px 12px",
|
|
149
|
+
marginBottom: 10,
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
Includes protected environment(s)
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
<div data-testid="sync-preview-list" style={{ marginBottom: 12 }}>
|
|
157
|
+
{plan.cells.map((cell) => (
|
|
158
|
+
<div
|
|
159
|
+
key={`${cell.namespace}/${cell.environment}`}
|
|
160
|
+
style={{
|
|
161
|
+
display: "flex",
|
|
162
|
+
alignItems: "center",
|
|
163
|
+
gap: 8,
|
|
164
|
+
padding: "4px 0",
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
<EnvBadge env={cell.environment} />
|
|
168
|
+
<span
|
|
169
|
+
style={{
|
|
170
|
+
fontFamily: theme.mono,
|
|
171
|
+
fontSize: 12,
|
|
172
|
+
color: theme.textMuted,
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
{cell.missingKeys.join(", ")}
|
|
176
|
+
</span>
|
|
177
|
+
</div>
|
|
178
|
+
))}
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
182
|
+
<Button variant="primary" data-testid="sync-execute-btn" onClick={handleSync}>
|
|
183
|
+
Sync Now
|
|
184
|
+
</Button>
|
|
185
|
+
<Button data-testid="sync-cancel-btn" onClick={onCancel}>
|
|
186
|
+
Cancel
|
|
187
|
+
</Button>
|
|
188
|
+
</div>
|
|
189
|
+
</>
|
|
190
|
+
)}
|
|
191
|
+
</>
|
|
192
|
+
)}
|
|
193
|
+
|
|
194
|
+
{phase === "syncing" && (
|
|
195
|
+
<div style={{ fontFamily: theme.sans, fontSize: 13, color: theme.accent }}>Syncing...</div>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
{phase === "done" && result && (
|
|
199
|
+
<div
|
|
200
|
+
data-testid="sync-done"
|
|
201
|
+
style={{ fontFamily: theme.sans, fontSize: 13, color: theme.green }}
|
|
202
|
+
>
|
|
203
|
+
Synced {result.totalKeysScaffolded} key{result.totalKeysScaffolded !== 1 ? "s" : ""}{" "}
|
|
204
|
+
across {result.modifiedCells.length} environment
|
|
205
|
+
{result.modifiedCells.length !== 1 ? "s" : ""}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
{phase === "error" && (
|
|
210
|
+
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
211
|
+
<span style={{ fontFamily: theme.sans, fontSize: 13, color: theme.red }}>{error}</span>
|
|
212
|
+
<Button onClick={onCancel}>Close</Button>
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { useState } from "react";
|
|
2
2
|
import { theme } from "../theme";
|
|
3
3
|
import { TopBar } from "../components/TopBar";
|
|
4
4
|
import { Button } from "../components/Button";
|
|
5
5
|
import { MatrixGrid } from "../components/MatrixGrid";
|
|
6
|
+
import { SyncPanel } from "../components/SyncPanel";
|
|
6
7
|
import type { ViewName } from "../components/Sidebar";
|
|
7
8
|
import type { ClefManifest, MatrixStatus } from "@clef-sh/core";
|
|
8
9
|
|
|
@@ -11,9 +12,17 @@ interface MatrixViewProps {
|
|
|
11
12
|
setNs: (ns: string) => void;
|
|
12
13
|
manifest: ClefManifest | null;
|
|
13
14
|
matrixStatuses: MatrixStatus[];
|
|
15
|
+
reloadMatrix?: () => void;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
export function MatrixView({
|
|
18
|
+
export function MatrixView({
|
|
19
|
+
setView,
|
|
20
|
+
setNs,
|
|
21
|
+
manifest,
|
|
22
|
+
matrixStatuses,
|
|
23
|
+
reloadMatrix,
|
|
24
|
+
}: MatrixViewProps) {
|
|
25
|
+
const [syncingNs, setSyncingNs] = useState<string | null>(null);
|
|
17
26
|
if (!manifest) {
|
|
18
27
|
return (
|
|
19
28
|
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
|
|
@@ -138,8 +147,21 @@ export function MatrixView({ setView, setNs, manifest, matrixStatuses }: MatrixV
|
|
|
138
147
|
setNs(nsName);
|
|
139
148
|
setView("editor");
|
|
140
149
|
}}
|
|
150
|
+
onSyncClick={(nsName) => setSyncingNs(nsName)}
|
|
151
|
+
syncingNs={syncingNs}
|
|
141
152
|
/>
|
|
142
153
|
|
|
154
|
+
{syncingNs && (
|
|
155
|
+
<SyncPanel
|
|
156
|
+
namespace={syncingNs}
|
|
157
|
+
onComplete={() => {
|
|
158
|
+
setSyncingNs(null);
|
|
159
|
+
reloadMatrix?.();
|
|
160
|
+
}}
|
|
161
|
+
onCancel={() => setSyncingNs(null)}
|
|
162
|
+
/>
|
|
163
|
+
)}
|
|
164
|
+
|
|
143
165
|
{/* Quick actions */}
|
|
144
166
|
<div style={{ marginTop: 20, display: "flex", gap: 10 }}>
|
|
145
167
|
<Button data-testid="diff-environments-btn" onClick={() => setView("diff")}>
|
|
@@ -42,7 +42,7 @@ describe("NamespaceEditor", () => {
|
|
|
42
42
|
} as Response);
|
|
43
43
|
|
|
44
44
|
await act(async () => {
|
|
45
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
45
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
expect(screen.getByText("DB_HOST")).toBeInTheDocument();
|
|
@@ -56,7 +56,7 @@ describe("NamespaceEditor", () => {
|
|
|
56
56
|
} as Response);
|
|
57
57
|
|
|
58
58
|
await act(async () => {
|
|
59
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
59
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
// Click production tab
|
|
@@ -77,7 +77,7 @@ describe("NamespaceEditor", () => {
|
|
|
77
77
|
} as Response);
|
|
78
78
|
|
|
79
79
|
await act(async () => {
|
|
80
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
80
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
await act(async () => {
|
|
@@ -94,7 +94,7 @@ describe("NamespaceEditor", () => {
|
|
|
94
94
|
} as Response);
|
|
95
95
|
|
|
96
96
|
await act(async () => {
|
|
97
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
97
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
expect(screen.getByText("Decrypt failed")).toBeInTheDocument();
|
|
@@ -107,7 +107,7 @@ describe("NamespaceEditor", () => {
|
|
|
107
107
|
} as Response);
|
|
108
108
|
|
|
109
109
|
await act(async () => {
|
|
110
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
110
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
await act(async () => {
|
|
@@ -126,7 +126,7 @@ describe("NamespaceEditor", () => {
|
|
|
126
126
|
} as Response);
|
|
127
127
|
|
|
128
128
|
await act(async () => {
|
|
129
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
129
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
130
130
|
});
|
|
131
131
|
|
|
132
132
|
await act(async () => {
|
|
@@ -149,7 +149,7 @@ describe("NamespaceEditor", () => {
|
|
|
149
149
|
global.fetch = fetchMock;
|
|
150
150
|
|
|
151
151
|
await act(async () => {
|
|
152
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
152
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
153
153
|
});
|
|
154
154
|
|
|
155
155
|
await act(async () => {
|
|
@@ -188,7 +188,7 @@ describe("NamespaceEditor", () => {
|
|
|
188
188
|
} as Response);
|
|
189
189
|
|
|
190
190
|
await act(async () => {
|
|
191
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
191
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
192
192
|
});
|
|
193
193
|
|
|
194
194
|
await act(async () => {
|
|
@@ -206,7 +206,7 @@ describe("NamespaceEditor", () => {
|
|
|
206
206
|
} as Response);
|
|
207
207
|
|
|
208
208
|
await act(async () => {
|
|
209
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
209
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
210
210
|
});
|
|
211
211
|
|
|
212
212
|
// Open overflow menu
|
|
@@ -230,7 +230,7 @@ describe("NamespaceEditor", () => {
|
|
|
230
230
|
} as Response);
|
|
231
231
|
|
|
232
232
|
await act(async () => {
|
|
233
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
233
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
234
234
|
});
|
|
235
235
|
|
|
236
236
|
await act(async () => {
|
|
@@ -260,7 +260,7 @@ describe("NamespaceEditor", () => {
|
|
|
260
260
|
global.fetch = fetchMock;
|
|
261
261
|
|
|
262
262
|
await act(async () => {
|
|
263
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
263
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
264
264
|
});
|
|
265
265
|
|
|
266
266
|
await act(async () => {
|
|
@@ -292,7 +292,7 @@ describe("NamespaceEditor", () => {
|
|
|
292
292
|
} as Response);
|
|
293
293
|
|
|
294
294
|
await act(async () => {
|
|
295
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
295
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
296
296
|
});
|
|
297
297
|
|
|
298
298
|
// Reveal the value
|
|
@@ -318,7 +318,7 @@ describe("NamespaceEditor", () => {
|
|
|
318
318
|
} as Response);
|
|
319
319
|
|
|
320
320
|
await act(async () => {
|
|
321
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
321
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
322
322
|
});
|
|
323
323
|
|
|
324
324
|
// Reveal a value to start the timer
|
|
@@ -347,7 +347,7 @@ describe("NamespaceEditor", () => {
|
|
|
347
347
|
} as Response);
|
|
348
348
|
|
|
349
349
|
await act(async () => {
|
|
350
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
350
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
351
351
|
});
|
|
352
352
|
|
|
353
353
|
// Switch to production tab
|
|
@@ -384,7 +384,7 @@ describe("NamespaceEditor", () => {
|
|
|
384
384
|
global.fetch = fetchMock;
|
|
385
385
|
|
|
386
386
|
await act(async () => {
|
|
387
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
387
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
388
388
|
});
|
|
389
389
|
|
|
390
390
|
// Switch to production tab
|
|
@@ -435,7 +435,7 @@ describe("NamespaceEditor", () => {
|
|
|
435
435
|
global.fetch = fetchMock;
|
|
436
436
|
|
|
437
437
|
await act(async () => {
|
|
438
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
438
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
439
439
|
});
|
|
440
440
|
|
|
441
441
|
// Switch to production tab
|
|
@@ -480,7 +480,7 @@ describe("NamespaceEditor", () => {
|
|
|
480
480
|
} as Response);
|
|
481
481
|
|
|
482
482
|
await act(async () => {
|
|
483
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
483
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
484
484
|
});
|
|
485
485
|
|
|
486
486
|
// Switch to production tab
|
|
@@ -509,7 +509,7 @@ describe("NamespaceEditor", () => {
|
|
|
509
509
|
} as Response);
|
|
510
510
|
|
|
511
511
|
await act(async () => {
|
|
512
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
512
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
513
513
|
});
|
|
514
514
|
|
|
515
515
|
// Stay on dev tab (default)
|
|
@@ -542,7 +542,7 @@ describe("NamespaceEditor", () => {
|
|
|
542
542
|
} as Response);
|
|
543
543
|
|
|
544
544
|
await act(async () => {
|
|
545
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
545
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
546
546
|
});
|
|
547
547
|
|
|
548
548
|
expect(screen.getByTestId("accept-value-DB_HOST")).toBeInTheDocument();
|
|
@@ -569,7 +569,7 @@ describe("NamespaceEditor", () => {
|
|
|
569
569
|
global.fetch = fetchMock;
|
|
570
570
|
|
|
571
571
|
await act(async () => {
|
|
572
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
572
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
573
573
|
});
|
|
574
574
|
|
|
575
575
|
// Click accept
|
|
@@ -598,7 +598,7 @@ describe("NamespaceEditor", () => {
|
|
|
598
598
|
} as Response);
|
|
599
599
|
|
|
600
600
|
await act(async () => {
|
|
601
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
601
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
602
602
|
});
|
|
603
603
|
|
|
604
604
|
await act(async () => {
|
|
@@ -615,7 +615,7 @@ describe("NamespaceEditor", () => {
|
|
|
615
615
|
} as Response);
|
|
616
616
|
|
|
617
617
|
await act(async () => {
|
|
618
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
618
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
619
619
|
});
|
|
620
620
|
|
|
621
621
|
await act(async () => {
|
|
@@ -638,7 +638,7 @@ describe("NamespaceEditor", () => {
|
|
|
638
638
|
global.fetch = fetchMock;
|
|
639
639
|
|
|
640
640
|
await act(async () => {
|
|
641
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
641
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
642
642
|
});
|
|
643
643
|
|
|
644
644
|
await act(async () => {
|
|
@@ -672,7 +672,7 @@ describe("NamespaceEditor", () => {
|
|
|
672
672
|
} as Response);
|
|
673
673
|
|
|
674
674
|
await act(async () => {
|
|
675
|
-
render(<NamespaceEditor ns="database" manifest={manifest}
|
|
675
|
+
render(<NamespaceEditor ns="database" manifest={manifest} />);
|
|
676
676
|
});
|
|
677
677
|
|
|
678
678
|
await act(async () => {
|
|
@@ -20,10 +20,9 @@ interface EditorRow {
|
|
|
20
20
|
interface NamespaceEditorProps {
|
|
21
21
|
ns: string;
|
|
22
22
|
manifest: ClefManifest | null;
|
|
23
|
-
onCommit: (message: string) => Promise<void>;
|
|
24
23
|
}
|
|
25
24
|
|
|
26
|
-
export function NamespaceEditor({ ns, manifest
|
|
25
|
+
export function NamespaceEditor({ ns, manifest }: NamespaceEditorProps) {
|
|
27
26
|
const [env, setEnv] = useState("");
|
|
28
27
|
const [rows, setRows] = useState<EditorRow[]>([]);
|
|
29
28
|
const [adding, setAdding] = useState(false);
|
|
@@ -35,8 +34,7 @@ export function NamespaceEditor({ ns, manifest, onCommit }: NamespaceEditorProps
|
|
|
35
34
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
|
36
35
|
const [loading, setLoading] = useState(false);
|
|
37
36
|
const [error, setError] = useState<string | null>(null);
|
|
38
|
-
const [
|
|
39
|
-
const [showCommitInput, setShowCommitInput] = useState(false);
|
|
37
|
+
const [saving, setSaving] = useState(false);
|
|
40
38
|
const [sopsInfo, setSopsInfo] = useState("");
|
|
41
39
|
const [lintIssues, setLintIssues] = useState<LintIssue[]>([]);
|
|
42
40
|
const [protectedConfirm, setProtectedConfirm] = useState<"save" | "add" | null>(null);
|
|
@@ -129,33 +127,25 @@ export function NamespaceEditor({ ns, manifest, onCommit }: NamespaceEditorProps
|
|
|
129
127
|
|
|
130
128
|
const handleSave = async (confirmed?: boolean) => {
|
|
131
129
|
const dirtyRows = rows.filter((r) => r.edited);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const payload: Record<string, unknown> = { value: row.value, commit: false };
|
|
130
|
+
if (dirtyRows.length === 0) return;
|
|
131
|
+
setSaving(true);
|
|
132
|
+
try {
|
|
133
|
+
// Each PUT auto-commits via the transaction manager, matching CLI
|
|
134
|
+
// behavior where each `clef set` is its own commit. Serialize so
|
|
135
|
+
// each transaction completes before the next starts.
|
|
136
|
+
for (const row of dirtyRows) {
|
|
137
|
+
const payload: Record<string, unknown> = { value: row.value };
|
|
141
138
|
if (confirmed) payload.confirmed = true;
|
|
142
|
-
|
|
139
|
+
await apiFetch(`/api/namespace/${ns}/${env}/${row.key}`, {
|
|
143
140
|
method: "PUT",
|
|
144
141
|
headers: { "Content-Type": "application/json" },
|
|
145
142
|
body: JSON.stringify(payload),
|
|
146
143
|
});
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
// returns. Without this, subsequent transactional operations (here in
|
|
152
|
-
// the UI or via the CLI) would race against the in-flight commit and
|
|
153
|
-
// hit the dirty-tree preflight refusal.
|
|
154
|
-
await onCommit(commitMessage);
|
|
144
|
+
}
|
|
145
|
+
await loadData();
|
|
146
|
+
} finally {
|
|
147
|
+
setSaving(false);
|
|
155
148
|
}
|
|
156
|
-
setShowCommitInput(false);
|
|
157
|
-
setCommitMessage("");
|
|
158
|
-
await loadData();
|
|
159
149
|
};
|
|
160
150
|
|
|
161
151
|
const handleAdd = async (confirmed?: boolean) => {
|
|
@@ -279,45 +269,21 @@ export function NamespaceEditor({ ns, manifest, onCommit }: NamespaceEditorProps
|
|
|
279
269
|
subtitle={`Namespace \u00B7 ${rows.length} keys`}
|
|
280
270
|
actions={
|
|
281
271
|
<>
|
|
282
|
-
{hasChanges &&
|
|
283
|
-
<Button
|
|
284
|
-
|
|
272
|
+
{hasChanges && (
|
|
273
|
+
<Button
|
|
274
|
+
variant="primary"
|
|
275
|
+
disabled={saving}
|
|
276
|
+
onClick={() => {
|
|
277
|
+
if (isProduction) {
|
|
278
|
+
setProtectedConfirm("save");
|
|
279
|
+
} else {
|
|
280
|
+
handleSave();
|
|
281
|
+
}
|
|
282
|
+
}}
|
|
283
|
+
>
|
|
284
|
+
{saving ? "Saving..." : "Save"}
|
|
285
285
|
</Button>
|
|
286
286
|
)}
|
|
287
|
-
{showCommitInput && (
|
|
288
|
-
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
|
|
289
|
-
<input
|
|
290
|
-
data-testid="commit-message-input"
|
|
291
|
-
value={commitMessage}
|
|
292
|
-
onChange={(e) => setCommitMessage(e.target.value)}
|
|
293
|
-
placeholder="Commit message..."
|
|
294
|
-
style={{
|
|
295
|
-
background: "#0D0F14",
|
|
296
|
-
border: `1px solid ${theme.borderLight}`,
|
|
297
|
-
borderRadius: 5,
|
|
298
|
-
padding: "5px 10px",
|
|
299
|
-
fontFamily: theme.mono,
|
|
300
|
-
fontSize: 12,
|
|
301
|
-
color: theme.text,
|
|
302
|
-
outline: "none",
|
|
303
|
-
width: 220,
|
|
304
|
-
}}
|
|
305
|
-
/>
|
|
306
|
-
<Button
|
|
307
|
-
variant="primary"
|
|
308
|
-
onClick={() => {
|
|
309
|
-
if (isProduction) {
|
|
310
|
-
setProtectedConfirm("save");
|
|
311
|
-
} else {
|
|
312
|
-
handleSave();
|
|
313
|
-
}
|
|
314
|
-
}}
|
|
315
|
-
>
|
|
316
|
-
Save & Commit
|
|
317
|
-
</Button>
|
|
318
|
-
<Button onClick={() => setShowCommitInput(false)}>Cancel</Button>
|
|
319
|
-
</div>
|
|
320
|
-
)}
|
|
321
287
|
<Button variant="primary" data-testid="add-key-btn" onClick={() => setAdding(true)}>
|
|
322
288
|
+ Add key
|
|
323
289
|
</Button>
|