@clef-sh/ui 0.1.15-beta.98 → 0.1.16-beta.110
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-CogUSGa_.js +26 -0
- package/dist/client/index.html +1 -1
- package/dist/client-lib/components/MatrixGrid.d.ts +4 -2
- 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 +96 -7
- package/dist/server/api.js.map +1 -1
- package/package.json +1 -1
- package/src/client/App.tsx +4 -14
- package/src/client/components/MatrixGrid.tsx +185 -146
- 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 +28 -3
- package/src/client/screens/NamespaceEditor.test.tsx +24 -24
- package/src/client/screens/NamespaceEditor.tsx +33 -64
- package/src/client/screens/ServiceIdentitiesScreen.tsx +381 -100
- package/dist/client/assets/index-rBYybJbt.js +0 -26
|
@@ -8,7 +8,9 @@ export interface MatrixGridProps {
|
|
|
8
8
|
namespaces: Array<{ name: string }>;
|
|
9
9
|
environments: Array<{ name: string }>;
|
|
10
10
|
matrixStatuses: MatrixStatus[];
|
|
11
|
-
onNamespaceClick?: (ns: string) => void;
|
|
11
|
+
onNamespaceClick?: (ns: string, env?: string) => void;
|
|
12
|
+
onSyncClick?: (ns: string) => void;
|
|
13
|
+
syncingNs?: string | null;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
function getStatusType(status: MatrixStatus): string {
|
|
@@ -38,6 +40,8 @@ export function MatrixGrid({
|
|
|
38
40
|
environments,
|
|
39
41
|
matrixStatuses,
|
|
40
42
|
onNamespaceClick,
|
|
43
|
+
onSyncClick,
|
|
44
|
+
syncingNs,
|
|
41
45
|
}: MatrixGridProps) {
|
|
42
46
|
return (
|
|
43
47
|
<div
|
|
@@ -98,168 +102,203 @@ export function MatrixGrid({
|
|
|
98
102
|
</div>
|
|
99
103
|
|
|
100
104
|
{/* Namespace rows */}
|
|
101
|
-
{namespaces.map((ns, i) =>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
onKeyDown={(e) => {
|
|
109
|
-
if (e.key === "Enter") onNamespaceClick?.(ns.name);
|
|
110
|
-
}}
|
|
111
|
-
style={{
|
|
112
|
-
display: "grid",
|
|
113
|
-
gridTemplateColumns: `180px ${environments.map(() => "1fr").join(" ")}`,
|
|
114
|
-
borderBottom: i < namespaces.length - 1 ? `1px solid ${theme.border}` : "none",
|
|
115
|
-
cursor: onNamespaceClick ? "pointer" : "default",
|
|
116
|
-
transition: "background 0.1s",
|
|
117
|
-
}}
|
|
118
|
-
onMouseEnter={(e) => {
|
|
119
|
-
(e.currentTarget as HTMLElement).style.background = theme.surfaceHover;
|
|
120
|
-
}}
|
|
121
|
-
onMouseLeave={(e) => {
|
|
122
|
-
(e.currentTarget as HTMLElement).style.background = "transparent";
|
|
123
|
-
}}
|
|
124
|
-
>
|
|
125
|
-
{/* Namespace label */}
|
|
105
|
+
{namespaces.map((ns, i) => {
|
|
106
|
+
const nsCells = matrixStatuses.filter((s) => s.cell.namespace === ns.name);
|
|
107
|
+
const hasDrift = nsCells.some((s) =>
|
|
108
|
+
s.issues.some((issue) => issue.type === "missing_keys"),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return (
|
|
126
112
|
<div
|
|
113
|
+
key={ns.name}
|
|
114
|
+
data-testid={`matrix-row-${ns.name}`}
|
|
115
|
+
role="button"
|
|
116
|
+
tabIndex={0}
|
|
117
|
+
onClick={() => onNamespaceClick?.(ns.name)}
|
|
118
|
+
onKeyDown={(e) => {
|
|
119
|
+
if (e.key === "Enter") onNamespaceClick?.(ns.name);
|
|
120
|
+
}}
|
|
121
|
+
// Cell-level clicks pass the environment; row-level is fallback
|
|
127
122
|
style={{
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
123
|
+
display: "grid",
|
|
124
|
+
gridTemplateColumns: `180px ${environments.map(() => "1fr").join(" ")}`,
|
|
125
|
+
borderBottom: i < namespaces.length - 1 ? `1px solid ${theme.border}` : "none",
|
|
126
|
+
cursor: onNamespaceClick ? "pointer" : "default",
|
|
127
|
+
transition: "background 0.1s",
|
|
128
|
+
}}
|
|
129
|
+
onMouseEnter={(e) => {
|
|
130
|
+
(e.currentTarget as HTMLElement).style.background = theme.surfaceHover;
|
|
131
|
+
}}
|
|
132
|
+
onMouseLeave={(e) => {
|
|
133
|
+
(e.currentTarget as HTMLElement).style.background = "transparent";
|
|
132
134
|
}}
|
|
133
135
|
>
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
fontFamily: theme.mono,
|
|
137
|
-
fontSize: 11,
|
|
138
|
-
color: theme.textDim,
|
|
139
|
-
}}
|
|
140
|
-
>
|
|
141
|
-
//
|
|
142
|
-
</span>
|
|
143
|
-
<span
|
|
136
|
+
{/* Namespace label */}
|
|
137
|
+
<div
|
|
144
138
|
style={{
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
139
|
+
padding: "16px 20px",
|
|
140
|
+
display: "flex",
|
|
141
|
+
alignItems: "center",
|
|
142
|
+
gap: 10,
|
|
149
143
|
}}
|
|
150
144
|
>
|
|
151
|
-
|
|
152
|
-
</span>
|
|
153
|
-
</div>
|
|
154
|
-
|
|
155
|
-
{/* Environment cells */}
|
|
156
|
-
{environments.map((env) => {
|
|
157
|
-
const cellStatus = matrixStatuses.find(
|
|
158
|
-
(s) => s.cell.namespace === ns.name && s.cell.environment === env.name,
|
|
159
|
-
);
|
|
160
|
-
const statusType = cellStatus ? getStatusType(cellStatus) : "ok";
|
|
161
|
-
const keyCount = cellStatus?.keyCount ?? 0;
|
|
162
|
-
const lastMod = cellStatus?.lastModified
|
|
163
|
-
? formatDate(
|
|
164
|
-
cellStatus.lastModified instanceof Date
|
|
165
|
-
? cellStatus.lastModified
|
|
166
|
-
: new Date(cellStatus.lastModified as unknown as string),
|
|
167
|
-
)
|
|
168
|
-
: "never";
|
|
169
|
-
const missingKeyCount = cellStatus
|
|
170
|
-
? new Set(
|
|
171
|
-
cellStatus.issues
|
|
172
|
-
.filter((i) => i.type === "missing_keys" && i.key)
|
|
173
|
-
.map((i) => i.key),
|
|
174
|
-
).size
|
|
175
|
-
: 0;
|
|
176
|
-
const warnKeyCount = cellStatus
|
|
177
|
-
? cellStatus.issues.filter((i) => i.type === "schema_warning").length
|
|
178
|
-
: 0;
|
|
179
|
-
const cellPending = cellStatus?.pendingCount ?? 0;
|
|
180
|
-
|
|
181
|
-
return (
|
|
182
|
-
<div
|
|
183
|
-
key={env.name}
|
|
145
|
+
<span
|
|
184
146
|
style={{
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
flexDirection: "column",
|
|
189
|
-
gap: 5,
|
|
147
|
+
fontFamily: theme.mono,
|
|
148
|
+
fontSize: 11,
|
|
149
|
+
color: theme.textDim,
|
|
190
150
|
}}
|
|
191
151
|
>
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
152
|
+
//
|
|
153
|
+
</span>
|
|
154
|
+
<span
|
|
155
|
+
style={{
|
|
156
|
+
fontFamily: theme.mono,
|
|
157
|
+
fontSize: 13,
|
|
158
|
+
fontWeight: 600,
|
|
159
|
+
color: theme.text,
|
|
160
|
+
flex: 1,
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
{ns.name}
|
|
164
|
+
</span>
|
|
165
|
+
{hasDrift && syncingNs !== ns.name && onSyncClick && (
|
|
166
|
+
<button
|
|
167
|
+
data-testid={`sync-btn-${ns.name}`}
|
|
168
|
+
onClick={(e) => {
|
|
169
|
+
e.stopPropagation();
|
|
170
|
+
onSyncClick(ns.name);
|
|
171
|
+
}}
|
|
172
|
+
style={{
|
|
173
|
+
fontFamily: theme.sans,
|
|
174
|
+
fontSize: 10,
|
|
175
|
+
fontWeight: 600,
|
|
176
|
+
color: theme.accent,
|
|
177
|
+
background: `${theme.accent}18`,
|
|
178
|
+
border: `1px solid ${theme.accent}33`,
|
|
179
|
+
borderRadius: 4,
|
|
180
|
+
padding: "2px 8px",
|
|
181
|
+
cursor: "pointer",
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
Sync
|
|
185
|
+
</button>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
{/* Environment cells */}
|
|
190
|
+
{environments.map((env) => {
|
|
191
|
+
const cellStatus = matrixStatuses.find(
|
|
192
|
+
(s) => s.cell.namespace === ns.name && s.cell.environment === env.name,
|
|
193
|
+
);
|
|
194
|
+
const statusType = cellStatus ? getStatusType(cellStatus) : "ok";
|
|
195
|
+
const keyCount = cellStatus?.keyCount ?? 0;
|
|
196
|
+
const lastMod = cellStatus?.lastModified
|
|
197
|
+
? formatDate(
|
|
198
|
+
cellStatus.lastModified instanceof Date
|
|
199
|
+
? cellStatus.lastModified
|
|
200
|
+
: new Date(cellStatus.lastModified as unknown as string),
|
|
201
|
+
)
|
|
202
|
+
: "never";
|
|
203
|
+
const missingKeyCount = cellStatus
|
|
204
|
+
? new Set(
|
|
205
|
+
cellStatus.issues
|
|
206
|
+
.filter((i) => i.type === "missing_keys" && i.key)
|
|
207
|
+
.map((i) => i.key),
|
|
208
|
+
).size
|
|
209
|
+
: 0;
|
|
210
|
+
const warnKeyCount = cellStatus
|
|
211
|
+
? cellStatus.issues.filter((i) => i.type === "schema_warning").length
|
|
212
|
+
: 0;
|
|
213
|
+
const cellPending = cellStatus?.pendingCount ?? 0;
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<div
|
|
217
|
+
key={env.name}
|
|
218
|
+
onClick={(e) => {
|
|
219
|
+
e.stopPropagation();
|
|
220
|
+
onNamespaceClick?.(ns.name, env.name);
|
|
221
|
+
}}
|
|
222
|
+
style={{
|
|
223
|
+
padding: "14px 20px",
|
|
224
|
+
borderLeft: `1px solid ${theme.border}`,
|
|
225
|
+
display: "flex",
|
|
226
|
+
flexDirection: "column",
|
|
227
|
+
gap: 5,
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
|
|
231
|
+
<StatusDot status={statusType} />
|
|
234
232
|
<span
|
|
235
233
|
style={{
|
|
236
234
|
fontFamily: theme.mono,
|
|
237
|
-
fontSize:
|
|
238
|
-
color: theme.
|
|
239
|
-
background: `${theme.accent}18`,
|
|
240
|
-
border: `1px solid ${theme.accent}33`,
|
|
241
|
-
borderRadius: 3,
|
|
242
|
-
padding: "1px 5px",
|
|
235
|
+
fontSize: 11,
|
|
236
|
+
color: theme.textMuted,
|
|
243
237
|
}}
|
|
244
238
|
>
|
|
245
|
-
{
|
|
239
|
+
{keyCount} keys
|
|
246
240
|
</span>
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
241
|
+
{missingKeyCount > 0 && (
|
|
242
|
+
<span
|
|
243
|
+
style={{
|
|
244
|
+
fontFamily: theme.mono,
|
|
245
|
+
fontSize: 10,
|
|
246
|
+
color: theme.red,
|
|
247
|
+
background: theme.redDim,
|
|
248
|
+
border: `1px solid ${theme.red}33`,
|
|
249
|
+
borderRadius: 3,
|
|
250
|
+
padding: "1px 5px",
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
-{missingKeyCount} missing
|
|
254
|
+
</span>
|
|
255
|
+
)}
|
|
256
|
+
{warnKeyCount > 0 && (
|
|
257
|
+
<span
|
|
258
|
+
style={{
|
|
259
|
+
fontFamily: theme.mono,
|
|
260
|
+
fontSize: 10,
|
|
261
|
+
color: theme.yellow,
|
|
262
|
+
background: theme.yellowDim,
|
|
263
|
+
border: `1px solid ${theme.yellow}33`,
|
|
264
|
+
borderRadius: 3,
|
|
265
|
+
padding: "1px 5px",
|
|
266
|
+
}}
|
|
267
|
+
>
|
|
268
|
+
{warnKeyCount} warn
|
|
269
|
+
</span>
|
|
270
|
+
)}
|
|
271
|
+
{cellPending > 0 && (
|
|
272
|
+
<span
|
|
273
|
+
style={{
|
|
274
|
+
fontFamily: theme.mono,
|
|
275
|
+
fontSize: 10,
|
|
276
|
+
color: theme.accent,
|
|
277
|
+
background: `${theme.accent}18`,
|
|
278
|
+
border: `1px solid ${theme.accent}33`,
|
|
279
|
+
borderRadius: 3,
|
|
280
|
+
padding: "1px 5px",
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
{cellPending} pending
|
|
284
|
+
</span>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
<div
|
|
288
|
+
style={{
|
|
289
|
+
fontFamily: theme.mono,
|
|
290
|
+
fontSize: 10,
|
|
291
|
+
color: theme.textDim,
|
|
292
|
+
}}
|
|
293
|
+
>
|
|
294
|
+
{lastMod}
|
|
295
|
+
</div>
|
|
257
296
|
</div>
|
|
258
|
-
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
)
|
|
297
|
+
);
|
|
298
|
+
})}
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
})}
|
|
263
302
|
</div>
|
|
264
303
|
);
|
|
265
304
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, fireEvent, act } from "@testing-library/react";
|
|
3
|
+
import "@testing-library/jest-dom";
|
|
4
|
+
import { SyncPanel } from "./SyncPanel";
|
|
5
|
+
|
|
6
|
+
const mockFetch = jest.fn();
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
jest.clearAllMocks();
|
|
9
|
+
global.fetch = mockFetch;
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
delete (global as Record<string, unknown>).fetch;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("SyncPanel", () => {
|
|
16
|
+
it("fetches preview on mount and shows missing keys", async () => {
|
|
17
|
+
mockFetch.mockResolvedValueOnce({
|
|
18
|
+
ok: true,
|
|
19
|
+
json: () =>
|
|
20
|
+
Promise.resolve({
|
|
21
|
+
cells: [
|
|
22
|
+
{
|
|
23
|
+
namespace: "payments",
|
|
24
|
+
environment: "production",
|
|
25
|
+
missingKeys: ["API_KEY"],
|
|
26
|
+
isProtected: true,
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
totalKeys: 1,
|
|
30
|
+
hasProtectedEnvs: true,
|
|
31
|
+
}),
|
|
32
|
+
} as Response);
|
|
33
|
+
|
|
34
|
+
await act(async () => {
|
|
35
|
+
render(<SyncPanel namespace="payments" onComplete={jest.fn()} onCancel={jest.fn()} />);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(screen.getByTestId("sync-preview-list")).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByText(/API_KEY/)).toBeInTheDocument();
|
|
40
|
+
expect(screen.getByText(/protected/i)).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("shows 'all in sync' when plan has 0 keys", async () => {
|
|
44
|
+
mockFetch.mockResolvedValueOnce({
|
|
45
|
+
ok: true,
|
|
46
|
+
json: () => Promise.resolve({ cells: [], totalKeys: 0, hasProtectedEnvs: false }),
|
|
47
|
+
} as Response);
|
|
48
|
+
|
|
49
|
+
await act(async () => {
|
|
50
|
+
render(<SyncPanel namespace="payments" onComplete={jest.fn()} onCancel={jest.fn()} />);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(screen.getByTestId("sync-in-sync")).toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("calls /api/sync on execute and shows done state", async () => {
|
|
57
|
+
mockFetch
|
|
58
|
+
.mockResolvedValueOnce({
|
|
59
|
+
ok: true,
|
|
60
|
+
json: () =>
|
|
61
|
+
Promise.resolve({
|
|
62
|
+
cells: [
|
|
63
|
+
{
|
|
64
|
+
namespace: "payments",
|
|
65
|
+
environment: "staging",
|
|
66
|
+
missingKeys: ["SECRET"],
|
|
67
|
+
isProtected: false,
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
totalKeys: 1,
|
|
71
|
+
hasProtectedEnvs: false,
|
|
72
|
+
}),
|
|
73
|
+
} as Response)
|
|
74
|
+
.mockResolvedValueOnce({
|
|
75
|
+
ok: true,
|
|
76
|
+
json: () =>
|
|
77
|
+
Promise.resolve({
|
|
78
|
+
success: true,
|
|
79
|
+
result: {
|
|
80
|
+
modifiedCells: ["payments/staging"],
|
|
81
|
+
scaffoldedKeys: { "payments/staging": ["SECRET"] },
|
|
82
|
+
totalKeysScaffolded: 1,
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
} as Response);
|
|
86
|
+
|
|
87
|
+
const onComplete = jest.fn();
|
|
88
|
+
await act(async () => {
|
|
89
|
+
render(<SyncPanel namespace="payments" onComplete={onComplete} onCancel={jest.fn()} />);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await act(async () => {
|
|
93
|
+
fireEvent.click(screen.getByTestId("sync-execute-btn"));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(screen.getByTestId("sync-done")).toBeInTheDocument();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("shows error on preview failure", async () => {
|
|
100
|
+
mockFetch.mockResolvedValueOnce({
|
|
101
|
+
ok: false,
|
|
102
|
+
json: () => Promise.resolve({ error: "Something went wrong" }),
|
|
103
|
+
} as Response);
|
|
104
|
+
|
|
105
|
+
await act(async () => {
|
|
106
|
+
render(<SyncPanel namespace="payments" onComplete={jest.fn()} onCancel={jest.fn()} />);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("calls onCancel when cancel is clicked", async () => {
|
|
113
|
+
mockFetch.mockResolvedValueOnce({
|
|
114
|
+
ok: true,
|
|
115
|
+
json: () =>
|
|
116
|
+
Promise.resolve({
|
|
117
|
+
cells: [
|
|
118
|
+
{
|
|
119
|
+
namespace: "payments",
|
|
120
|
+
environment: "staging",
|
|
121
|
+
missingKeys: ["KEY"],
|
|
122
|
+
isProtected: false,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
totalKeys: 1,
|
|
126
|
+
hasProtectedEnvs: false,
|
|
127
|
+
}),
|
|
128
|
+
} as Response);
|
|
129
|
+
|
|
130
|
+
const onCancel = jest.fn();
|
|
131
|
+
await act(async () => {
|
|
132
|
+
render(<SyncPanel namespace="payments" onComplete={jest.fn()} onCancel={onCancel} />);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
fireEvent.click(screen.getByTestId("sync-cancel-btn"));
|
|
136
|
+
expect(onCancel).toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
});
|