@clef-sh/ui 0.1.16 → 0.1.18
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-DA0UG2qb.js +38 -0
- package/dist/client/index.html +1 -1
- package/dist/client-lib/components/Sidebar.d.ts +3 -2
- package/dist/client-lib/components/Sidebar.d.ts.map +1 -1
- package/dist/server/api.d.ts.map +1 -1
- package/dist/server/api.js +106 -4
- package/dist/server/api.js.map +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +4 -1
- package/dist/server/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/App.tsx +18 -1
- package/src/client/components/Sidebar.tsx +28 -2
- package/src/client/screens/NamespaceEditor.test.tsx +100 -0
- package/src/client/screens/NamespaceEditor.tsx +24 -3
- package/src/client/screens/PolicyView.test.tsx +278 -0
- package/src/client/screens/PolicyView.tsx +731 -0
- package/dist/client/assets/index-CogUSGa_.js +0 -26
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
2
|
+
import { theme, ENV_COLORS } from "../theme";
|
|
3
|
+
import { apiFetch } from "../api";
|
|
4
|
+
import { TopBar } from "../components/TopBar";
|
|
5
|
+
import { Button } from "../components/Button";
|
|
6
|
+
import { EnvBadge } from "../components/EnvBadge";
|
|
7
|
+
import type { ViewName } from "../components/Sidebar";
|
|
8
|
+
import type { PolicyDocument, FileRotationStatus, KeyRotationStatus } from "@clef-sh/core";
|
|
9
|
+
|
|
10
|
+
interface PolicyViewProps {
|
|
11
|
+
setView: (view: ViewName) => void;
|
|
12
|
+
setNs: (ns: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface PolicyCheckResponse {
|
|
16
|
+
files: FileRotationStatus[];
|
|
17
|
+
summary: {
|
|
18
|
+
total_files: number;
|
|
19
|
+
compliant: number;
|
|
20
|
+
rotation_overdue: number;
|
|
21
|
+
unknown_metadata: number;
|
|
22
|
+
};
|
|
23
|
+
policy: PolicyDocument;
|
|
24
|
+
source: "file" | "default";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type StatusFilter = "all" | "overdue" | "unknown" | "ok";
|
|
28
|
+
|
|
29
|
+
const MS_PER_DAY = 86_400_000;
|
|
30
|
+
|
|
31
|
+
const STATUS_META = {
|
|
32
|
+
overdue: { color: theme.red, bg: theme.redDim, label: "Overdue", icon: "\u2715" },
|
|
33
|
+
unknown: { color: theme.yellow, bg: theme.yellowDim, label: "Unknown", icon: "?" },
|
|
34
|
+
ok: { color: theme.green, bg: theme.greenDim, label: "OK", icon: "\u2713" },
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A flattened row — one per (file, key) pair. The PolicyView renders these
|
|
39
|
+
* grouped by per-key status so users see the actual policy signal rather
|
|
40
|
+
* than a file-level aggregate.
|
|
41
|
+
*/
|
|
42
|
+
interface KeyRow {
|
|
43
|
+
key: KeyRotationStatus;
|
|
44
|
+
file: FileRotationStatus;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function keyRowStatus(k: KeyRotationStatus): "overdue" | "unknown" | "ok" {
|
|
48
|
+
// Unknown is checked before overdue because `rotation_overdue` is only
|
|
49
|
+
// meaningful when `last_rotated_known` is true.
|
|
50
|
+
if (!k.last_rotated_known) return "unknown";
|
|
51
|
+
if (k.rotation_overdue) return "overdue";
|
|
52
|
+
return "ok";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ageInDays(iso: string): number {
|
|
56
|
+
return Math.floor((Date.now() - new Date(iso).getTime()) / MS_PER_DAY);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Derive max_age_days for a key from its rotation_due vs last_rotated_at. */
|
|
60
|
+
function keyLimitDays(k: KeyRotationStatus): number | null {
|
|
61
|
+
if (!k.last_rotated_at || !k.rotation_due) return null;
|
|
62
|
+
const due = new Date(k.rotation_due).getTime();
|
|
63
|
+
const last = new Date(k.last_rotated_at).getTime();
|
|
64
|
+
return Math.round((due - last) / MS_PER_DAY);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function PolicyView({ setView, setNs }: PolicyViewProps) {
|
|
68
|
+
const [data, setData] = useState<PolicyCheckResponse | null>(null);
|
|
69
|
+
const [rawYaml, setRawYaml] = useState<string>("");
|
|
70
|
+
const [loading, setLoading] = useState(false);
|
|
71
|
+
const [filter, setFilter] = useState<StatusFilter>("all");
|
|
72
|
+
const [showYaml, setShowYaml] = useState(false);
|
|
73
|
+
|
|
74
|
+
const loadPolicy = useCallback(async () => {
|
|
75
|
+
setLoading(true);
|
|
76
|
+
try {
|
|
77
|
+
const [checkRes, policyRes] = await Promise.all([
|
|
78
|
+
apiFetch("/api/policy/check"),
|
|
79
|
+
apiFetch("/api/policy"),
|
|
80
|
+
]);
|
|
81
|
+
if (checkRes.ok) {
|
|
82
|
+
setData((await checkRes.json()) as PolicyCheckResponse);
|
|
83
|
+
}
|
|
84
|
+
if (policyRes.ok) {
|
|
85
|
+
const p = (await policyRes.json()) as { rawYaml: string };
|
|
86
|
+
setRawYaml(p.rawYaml);
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// Silently fail — user can retry
|
|
90
|
+
} finally {
|
|
91
|
+
setLoading(false);
|
|
92
|
+
}
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
loadPolicy();
|
|
97
|
+
}, [loadPolicy]);
|
|
98
|
+
|
|
99
|
+
// Extract the namespace from a cell path. The cell path is shaped like
|
|
100
|
+
// `{prefix...}/<namespace>/<environment>.enc.yaml` per the manifest's
|
|
101
|
+
// file_pattern — so the second-to-last segment is always the namespace,
|
|
102
|
+
// regardless of how many leading directories the repo uses. Mirrors
|
|
103
|
+
// LintView's handleNavigate.
|
|
104
|
+
const namespaceFromPath = (filePath: string): string | undefined => {
|
|
105
|
+
const parts = filePath.split("/");
|
|
106
|
+
return parts.length >= 2 ? parts[parts.length - 2] : parts[0];
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const handleNavigate = (file: FileRotationStatus) => {
|
|
110
|
+
const ns = namespaceFromPath(file.path);
|
|
111
|
+
if (ns) {
|
|
112
|
+
setNs(ns);
|
|
113
|
+
setView("editor");
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const files = data?.files ?? [];
|
|
118
|
+
const summary = data?.summary;
|
|
119
|
+
const policy = data?.policy;
|
|
120
|
+
const source = data?.source;
|
|
121
|
+
|
|
122
|
+
// Flatten (file, key) pairs so we can group by per-key status. This is the
|
|
123
|
+
// authoritative view of rotation compliance — unknown rotation state on a
|
|
124
|
+
// single key fails the gate regardless of how many other keys are fresh.
|
|
125
|
+
const allRows: KeyRow[] = useMemo(
|
|
126
|
+
() => files.flatMap((f) => f.keys.map((k) => ({ file: f, key: k }))),
|
|
127
|
+
[files],
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const visible = useMemo(
|
|
131
|
+
() => (filter === "all" ? allRows : allRows.filter((r) => keyRowStatus(r.key) === filter)),
|
|
132
|
+
[allRows, filter],
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const counts = useMemo(() => {
|
|
136
|
+
let overdue = 0;
|
|
137
|
+
let unknown = 0;
|
|
138
|
+
let ok = 0;
|
|
139
|
+
for (const r of allRows) {
|
|
140
|
+
const s = keyRowStatus(r.key);
|
|
141
|
+
if (s === "overdue") overdue++;
|
|
142
|
+
else if (s === "unknown") unknown++;
|
|
143
|
+
else ok++;
|
|
144
|
+
}
|
|
145
|
+
return { overdue, unknown, ok, total: allRows.length };
|
|
146
|
+
}, [allRows]);
|
|
147
|
+
|
|
148
|
+
const allCompliant = counts.total > 0 && counts.overdue === 0 && counts.unknown === 0;
|
|
149
|
+
const noFiles = !loading && files.length === 0;
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
|
|
153
|
+
<TopBar
|
|
154
|
+
title="Policy"
|
|
155
|
+
subtitle={"clef policy check \u2014 rotation verdicts"}
|
|
156
|
+
actions={<Button onClick={loadPolicy}>{"\u21BB"} Re-run</Button>}
|
|
157
|
+
/>
|
|
158
|
+
|
|
159
|
+
{/* Policy summary card */}
|
|
160
|
+
{policy && (
|
|
161
|
+
<div
|
|
162
|
+
style={{
|
|
163
|
+
padding: "16px 24px",
|
|
164
|
+
background: theme.surface,
|
|
165
|
+
borderBottom: `1px solid ${theme.border}`,
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
|
|
169
|
+
<span
|
|
170
|
+
style={{
|
|
171
|
+
fontFamily: theme.sans,
|
|
172
|
+
fontSize: 11,
|
|
173
|
+
fontWeight: 600,
|
|
174
|
+
color: theme.textDim,
|
|
175
|
+
letterSpacing: "0.08em",
|
|
176
|
+
textTransform: "uppercase",
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
Default
|
|
180
|
+
</span>
|
|
181
|
+
<span
|
|
182
|
+
style={{
|
|
183
|
+
fontFamily: theme.mono,
|
|
184
|
+
fontSize: 13,
|
|
185
|
+
color: theme.text,
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
{policy.rotation?.max_age_days ?? "\u2014"}
|
|
189
|
+
<span style={{ color: theme.textMuted, marginLeft: 2 }}>d</span>
|
|
190
|
+
</span>
|
|
191
|
+
|
|
192
|
+
{policy.rotation?.environments &&
|
|
193
|
+
Object.entries(policy.rotation.environments).map(([env, cfg]) => {
|
|
194
|
+
const c = ENV_COLORS[env] ?? { color: theme.textMuted, bg: "transparent" };
|
|
195
|
+
return (
|
|
196
|
+
<span
|
|
197
|
+
key={env}
|
|
198
|
+
style={{
|
|
199
|
+
display: "inline-flex",
|
|
200
|
+
alignItems: "center",
|
|
201
|
+
gap: 6,
|
|
202
|
+
padding: "2px 8px",
|
|
203
|
+
borderRadius: 4,
|
|
204
|
+
background: c.bg,
|
|
205
|
+
border: `1px solid ${c.color}33`,
|
|
206
|
+
fontFamily: theme.mono,
|
|
207
|
+
fontSize: 11,
|
|
208
|
+
color: c.color,
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
<span style={{ fontWeight: 700, letterSpacing: "0.06em" }}>
|
|
212
|
+
{env.toUpperCase()}
|
|
213
|
+
</span>
|
|
214
|
+
<span>{cfg.max_age_days}d</span>
|
|
215
|
+
</span>
|
|
216
|
+
);
|
|
217
|
+
})}
|
|
218
|
+
|
|
219
|
+
<div style={{ flex: 1 }} />
|
|
220
|
+
|
|
221
|
+
<span
|
|
222
|
+
data-testid="policy-source"
|
|
223
|
+
style={{
|
|
224
|
+
fontFamily: theme.mono,
|
|
225
|
+
fontSize: 10,
|
|
226
|
+
color: source === "file" ? theme.green : theme.textMuted,
|
|
227
|
+
background: source === "file" ? theme.greenDim : "transparent",
|
|
228
|
+
border: `1px solid ${source === "file" ? `${theme.green}44` : theme.border}`,
|
|
229
|
+
borderRadius: 3,
|
|
230
|
+
padding: "2px 8px",
|
|
231
|
+
}}
|
|
232
|
+
>
|
|
233
|
+
{source === "file" ? ".clef/policy.yaml" : "Built-in default"}
|
|
234
|
+
</span>
|
|
235
|
+
|
|
236
|
+
{rawYaml && (
|
|
237
|
+
<button
|
|
238
|
+
data-testid="toggle-yaml"
|
|
239
|
+
onClick={() => setShowYaml((v) => !v)}
|
|
240
|
+
style={{
|
|
241
|
+
fontFamily: theme.sans,
|
|
242
|
+
fontSize: 11,
|
|
243
|
+
color: theme.accent,
|
|
244
|
+
background: "transparent",
|
|
245
|
+
border: `1px solid ${theme.accent}33`,
|
|
246
|
+
borderRadius: 4,
|
|
247
|
+
padding: "3px 9px",
|
|
248
|
+
cursor: "pointer",
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
{showYaml ? "Hide YAML" : "View YAML"}
|
|
252
|
+
</button>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
{showYaml && rawYaml && (
|
|
257
|
+
<pre
|
|
258
|
+
data-testid="raw-yaml"
|
|
259
|
+
style={{
|
|
260
|
+
marginTop: 12,
|
|
261
|
+
padding: "12px 14px",
|
|
262
|
+
background: "#0D0F14",
|
|
263
|
+
border: `1px solid ${theme.borderLight}`,
|
|
264
|
+
borderRadius: 6,
|
|
265
|
+
fontFamily: theme.mono,
|
|
266
|
+
fontSize: 11,
|
|
267
|
+
color: theme.text,
|
|
268
|
+
overflow: "auto",
|
|
269
|
+
maxHeight: 200,
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
{rawYaml}
|
|
273
|
+
</pre>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
|
|
278
|
+
{/* Summary chips — per-key counts, not per-file */}
|
|
279
|
+
{!loading && counts.total > 0 && (
|
|
280
|
+
<div
|
|
281
|
+
style={{
|
|
282
|
+
padding: "14px 24px",
|
|
283
|
+
background: "#0D0F14",
|
|
284
|
+
borderBottom: `1px solid ${theme.border}`,
|
|
285
|
+
display: "flex",
|
|
286
|
+
alignItems: "center",
|
|
287
|
+
gap: 10,
|
|
288
|
+
flexWrap: "wrap",
|
|
289
|
+
}}
|
|
290
|
+
>
|
|
291
|
+
{[
|
|
292
|
+
{
|
|
293
|
+
key: "all" as const,
|
|
294
|
+
label: "All keys",
|
|
295
|
+
count: counts.total,
|
|
296
|
+
color: theme.textMuted,
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
key: "overdue" as const,
|
|
300
|
+
label: "Overdue",
|
|
301
|
+
count: counts.overdue,
|
|
302
|
+
color: theme.red,
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
key: "unknown" as const,
|
|
306
|
+
label: "Unknown",
|
|
307
|
+
count: counts.unknown,
|
|
308
|
+
color: theme.yellow,
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
key: "ok" as const,
|
|
312
|
+
label: "Compliant",
|
|
313
|
+
count: counts.ok,
|
|
314
|
+
color: theme.green,
|
|
315
|
+
},
|
|
316
|
+
].map((f) => (
|
|
317
|
+
<button
|
|
318
|
+
key={f.key}
|
|
319
|
+
data-testid={`filter-${f.key}`}
|
|
320
|
+
onClick={() => setFilter(f.key)}
|
|
321
|
+
style={{
|
|
322
|
+
display: "flex",
|
|
323
|
+
alignItems: "center",
|
|
324
|
+
gap: 6,
|
|
325
|
+
padding: "5px 12px",
|
|
326
|
+
borderRadius: 20,
|
|
327
|
+
cursor: "pointer",
|
|
328
|
+
fontFamily: theme.sans,
|
|
329
|
+
fontSize: 12,
|
|
330
|
+
fontWeight: filter === f.key ? 600 : 400,
|
|
331
|
+
color: filter === f.key ? f.color : theme.textMuted,
|
|
332
|
+
background: filter === f.key ? `${f.color}18` : "transparent",
|
|
333
|
+
border: `1px solid ${filter === f.key ? `${f.color}55` : theme.border}`,
|
|
334
|
+
transition: "all 0.12s",
|
|
335
|
+
}}
|
|
336
|
+
>
|
|
337
|
+
<span
|
|
338
|
+
style={{
|
|
339
|
+
fontFamily: theme.mono,
|
|
340
|
+
fontSize: 11,
|
|
341
|
+
fontWeight: 700,
|
|
342
|
+
color: f.color,
|
|
343
|
+
}}
|
|
344
|
+
>
|
|
345
|
+
{f.count}
|
|
346
|
+
</span>
|
|
347
|
+
{f.label}
|
|
348
|
+
</button>
|
|
349
|
+
))}
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
352
|
+
|
|
353
|
+
<div style={{ flex: 1, overflow: "auto", padding: 24 }}>
|
|
354
|
+
{loading && (
|
|
355
|
+
<>
|
|
356
|
+
<style>{`
|
|
357
|
+
@keyframes clef-policy-line {
|
|
358
|
+
0% { transform: scaleX(0); opacity: 0; }
|
|
359
|
+
10% { opacity: 1; }
|
|
360
|
+
50% { transform: scaleX(1); opacity: 1; }
|
|
361
|
+
80% { transform: scaleX(1); opacity: 0.3; }
|
|
362
|
+
100% { transform: scaleX(0); opacity: 0; }
|
|
363
|
+
}
|
|
364
|
+
@keyframes clef-policy-glow {
|
|
365
|
+
0%, 100% { opacity: 0.4; }
|
|
366
|
+
50% { opacity: 1; }
|
|
367
|
+
}
|
|
368
|
+
`}</style>
|
|
369
|
+
<div
|
|
370
|
+
style={{
|
|
371
|
+
display: "flex",
|
|
372
|
+
alignItems: "center",
|
|
373
|
+
justifyContent: "center",
|
|
374
|
+
padding: "48px 24px",
|
|
375
|
+
}}
|
|
376
|
+
>
|
|
377
|
+
<div
|
|
378
|
+
style={{
|
|
379
|
+
background: theme.surface,
|
|
380
|
+
border: `1px solid ${theme.border}`,
|
|
381
|
+
borderRadius: 10,
|
|
382
|
+
padding: "28px 40px",
|
|
383
|
+
textAlign: "center",
|
|
384
|
+
minWidth: 200,
|
|
385
|
+
}}
|
|
386
|
+
>
|
|
387
|
+
<div style={{ marginBottom: 16, display: "flex", flexDirection: "column", gap: 6 }}>
|
|
388
|
+
{[0, 0.3, 0.6].map((delay, i) => (
|
|
389
|
+
<div
|
|
390
|
+
key={i}
|
|
391
|
+
style={{
|
|
392
|
+
height: 3,
|
|
393
|
+
borderRadius: 2,
|
|
394
|
+
background: theme.accent,
|
|
395
|
+
transformOrigin: "left",
|
|
396
|
+
animation: `clef-policy-line 1.8s ease-in-out ${delay}s infinite`,
|
|
397
|
+
opacity: 0,
|
|
398
|
+
width: [120, 90, 105][i],
|
|
399
|
+
}}
|
|
400
|
+
/>
|
|
401
|
+
))}
|
|
402
|
+
</div>
|
|
403
|
+
<div
|
|
404
|
+
style={{
|
|
405
|
+
fontFamily: theme.mono,
|
|
406
|
+
fontSize: 11,
|
|
407
|
+
color: theme.textMuted,
|
|
408
|
+
animation: "clef-policy-glow 1.8s ease-in-out infinite",
|
|
409
|
+
}}
|
|
410
|
+
>
|
|
411
|
+
Evaluating policy...
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
</>
|
|
416
|
+
)}
|
|
417
|
+
|
|
418
|
+
{!loading && noFiles && (
|
|
419
|
+
<div
|
|
420
|
+
data-testid="no-files"
|
|
421
|
+
style={{
|
|
422
|
+
display: "flex",
|
|
423
|
+
flexDirection: "column",
|
|
424
|
+
alignItems: "center",
|
|
425
|
+
justifyContent: "center",
|
|
426
|
+
gap: 14,
|
|
427
|
+
padding: "60px 0",
|
|
428
|
+
color: theme.textMuted,
|
|
429
|
+
fontFamily: theme.sans,
|
|
430
|
+
fontSize: 13,
|
|
431
|
+
}}
|
|
432
|
+
>
|
|
433
|
+
No matrix files to evaluate.
|
|
434
|
+
</div>
|
|
435
|
+
)}
|
|
436
|
+
|
|
437
|
+
{!loading && allCompliant && (
|
|
438
|
+
<div
|
|
439
|
+
data-testid="all-compliant"
|
|
440
|
+
style={{
|
|
441
|
+
display: "flex",
|
|
442
|
+
flexDirection: "column",
|
|
443
|
+
alignItems: "center",
|
|
444
|
+
justifyContent: "center",
|
|
445
|
+
gap: 14,
|
|
446
|
+
padding: "60px 0",
|
|
447
|
+
}}
|
|
448
|
+
>
|
|
449
|
+
<div
|
|
450
|
+
style={{
|
|
451
|
+
width: 56,
|
|
452
|
+
height: 56,
|
|
453
|
+
borderRadius: "50%",
|
|
454
|
+
background: theme.greenDim,
|
|
455
|
+
border: `1px solid ${theme.green}44`,
|
|
456
|
+
display: "flex",
|
|
457
|
+
alignItems: "center",
|
|
458
|
+
justifyContent: "center",
|
|
459
|
+
fontSize: 24,
|
|
460
|
+
}}
|
|
461
|
+
>
|
|
462
|
+
{"\u2713"}
|
|
463
|
+
</div>
|
|
464
|
+
<div
|
|
465
|
+
style={{
|
|
466
|
+
fontFamily: theme.sans,
|
|
467
|
+
fontWeight: 600,
|
|
468
|
+
fontSize: 16,
|
|
469
|
+
color: theme.green,
|
|
470
|
+
}}
|
|
471
|
+
>
|
|
472
|
+
All compliant
|
|
473
|
+
</div>
|
|
474
|
+
<div
|
|
475
|
+
style={{
|
|
476
|
+
fontFamily: theme.mono,
|
|
477
|
+
fontSize: 12,
|
|
478
|
+
color: theme.textMuted,
|
|
479
|
+
}}
|
|
480
|
+
>
|
|
481
|
+
{counts.total} key{counts.total === 1 ? "" : "s"} within rotation window across{" "}
|
|
482
|
+
{summary?.total_files ?? 0} file{summary?.total_files === 1 ? "" : "s"}
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
)}
|
|
486
|
+
|
|
487
|
+
{/* Grouped per-key rows */}
|
|
488
|
+
{!loading &&
|
|
489
|
+
!allCompliant &&
|
|
490
|
+
!noFiles &&
|
|
491
|
+
policy &&
|
|
492
|
+
(["overdue", "unknown", "ok"] as const).map((status) => {
|
|
493
|
+
if (filter !== "all" && filter !== status) return null;
|
|
494
|
+
const group = visible.filter((r) => keyRowStatus(r.key) === status);
|
|
495
|
+
if (!group.length) return null;
|
|
496
|
+
const meta = STATUS_META[status];
|
|
497
|
+
|
|
498
|
+
return (
|
|
499
|
+
<div key={status} style={{ marginBottom: 24 }}>
|
|
500
|
+
<div
|
|
501
|
+
style={{
|
|
502
|
+
display: "flex",
|
|
503
|
+
alignItems: "center",
|
|
504
|
+
gap: 10,
|
|
505
|
+
marginBottom: 10,
|
|
506
|
+
}}
|
|
507
|
+
>
|
|
508
|
+
<div
|
|
509
|
+
style={{
|
|
510
|
+
width: 22,
|
|
511
|
+
height: 22,
|
|
512
|
+
borderRadius: "50%",
|
|
513
|
+
background: meta.bg,
|
|
514
|
+
border: `1px solid ${meta.color}44`,
|
|
515
|
+
display: "flex",
|
|
516
|
+
alignItems: "center",
|
|
517
|
+
justifyContent: "center",
|
|
518
|
+
fontFamily: theme.mono,
|
|
519
|
+
fontSize: 11,
|
|
520
|
+
fontWeight: 700,
|
|
521
|
+
color: meta.color,
|
|
522
|
+
}}
|
|
523
|
+
>
|
|
524
|
+
{meta.icon}
|
|
525
|
+
</div>
|
|
526
|
+
<span
|
|
527
|
+
style={{
|
|
528
|
+
fontFamily: theme.sans,
|
|
529
|
+
fontWeight: 600,
|
|
530
|
+
fontSize: 13,
|
|
531
|
+
color: meta.color,
|
|
532
|
+
}}
|
|
533
|
+
>
|
|
534
|
+
{meta.label}
|
|
535
|
+
</span>
|
|
536
|
+
<span
|
|
537
|
+
style={{
|
|
538
|
+
fontFamily: theme.mono,
|
|
539
|
+
fontSize: 10,
|
|
540
|
+
color: meta.color,
|
|
541
|
+
background: meta.bg,
|
|
542
|
+
border: `1px solid ${meta.color}33`,
|
|
543
|
+
borderRadius: 10,
|
|
544
|
+
padding: "1px 8px",
|
|
545
|
+
}}
|
|
546
|
+
>
|
|
547
|
+
{group.length}
|
|
548
|
+
</span>
|
|
549
|
+
</div>
|
|
550
|
+
|
|
551
|
+
<div
|
|
552
|
+
style={{
|
|
553
|
+
background: theme.surface,
|
|
554
|
+
border: `1px solid ${theme.border}`,
|
|
555
|
+
borderRadius: 10,
|
|
556
|
+
overflow: "hidden",
|
|
557
|
+
}}
|
|
558
|
+
>
|
|
559
|
+
{group.map((row, i) => {
|
|
560
|
+
const { file, key } = row;
|
|
561
|
+
const limit = keyLimitDays(key);
|
|
562
|
+
const nsHint = namespaceFromPath(file.path) ?? "<namespace>";
|
|
563
|
+
const message =
|
|
564
|
+
status === "unknown"
|
|
565
|
+
? `No rotation record \u00B7 run clef set ${nsHint}/${file.environment} ${key.key} to establish`
|
|
566
|
+
: key.last_rotated_at
|
|
567
|
+
? `Last rotated ${ageInDays(key.last_rotated_at)}d ago \u00B7 limit ${limit ?? "?"}d \u00B7 ${key.rotation_count} rotation${key.rotation_count === 1 ? "" : "s"}`
|
|
568
|
+
: `Rotation state inconsistent`;
|
|
569
|
+
const statusTag =
|
|
570
|
+
status === "overdue" ? `${meta.label} ${key.days_overdue}d` : meta.label;
|
|
571
|
+
|
|
572
|
+
return (
|
|
573
|
+
<div
|
|
574
|
+
key={`${file.path}-${key.key}-${i}`}
|
|
575
|
+
style={{
|
|
576
|
+
display: "flex",
|
|
577
|
+
alignItems: "flex-start",
|
|
578
|
+
borderBottom: i < group.length - 1 ? `1px solid ${theme.border}` : "none",
|
|
579
|
+
borderLeft: `3px solid ${meta.color}66`,
|
|
580
|
+
transition: "background 0.1s",
|
|
581
|
+
padding: "14px 18px",
|
|
582
|
+
gap: 14,
|
|
583
|
+
}}
|
|
584
|
+
>
|
|
585
|
+
<div style={{ flexShrink: 0, paddingTop: 2 }}>
|
|
586
|
+
<span
|
|
587
|
+
style={{
|
|
588
|
+
fontFamily: theme.mono,
|
|
589
|
+
fontSize: 9,
|
|
590
|
+
fontWeight: 700,
|
|
591
|
+
color: meta.color,
|
|
592
|
+
background: `${meta.color}18`,
|
|
593
|
+
border: `1px solid ${meta.color}33`,
|
|
594
|
+
borderRadius: 3,
|
|
595
|
+
padding: "2px 6px",
|
|
596
|
+
letterSpacing: "0.07em",
|
|
597
|
+
textTransform: "uppercase",
|
|
598
|
+
}}
|
|
599
|
+
>
|
|
600
|
+
{statusTag}
|
|
601
|
+
</span>
|
|
602
|
+
</div>
|
|
603
|
+
|
|
604
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
605
|
+
<div
|
|
606
|
+
style={{
|
|
607
|
+
display: "flex",
|
|
608
|
+
alignItems: "center",
|
|
609
|
+
gap: 8,
|
|
610
|
+
marginBottom: 4,
|
|
611
|
+
flexWrap: "wrap",
|
|
612
|
+
}}
|
|
613
|
+
>
|
|
614
|
+
<span
|
|
615
|
+
data-testid={`key-ref-${key.key}`}
|
|
616
|
+
style={{
|
|
617
|
+
fontFamily: theme.mono,
|
|
618
|
+
fontSize: 13,
|
|
619
|
+
fontWeight: 700,
|
|
620
|
+
color: theme.text,
|
|
621
|
+
}}
|
|
622
|
+
>
|
|
623
|
+
{key.key}
|
|
624
|
+
</span>
|
|
625
|
+
<span
|
|
626
|
+
style={{
|
|
627
|
+
fontFamily: theme.mono,
|
|
628
|
+
fontSize: 10,
|
|
629
|
+
color: theme.textMuted,
|
|
630
|
+
}}
|
|
631
|
+
>
|
|
632
|
+
{"\u2190"}
|
|
633
|
+
</span>
|
|
634
|
+
<span
|
|
635
|
+
data-testid={`file-ref-${file.path}`}
|
|
636
|
+
role="link"
|
|
637
|
+
tabIndex={0}
|
|
638
|
+
onClick={() => handleNavigate(file)}
|
|
639
|
+
onKeyDown={(e) => {
|
|
640
|
+
if (e.key === "Enter") handleNavigate(file);
|
|
641
|
+
}}
|
|
642
|
+
style={{
|
|
643
|
+
fontFamily: theme.mono,
|
|
644
|
+
fontSize: 11,
|
|
645
|
+
fontWeight: 500,
|
|
646
|
+
color: theme.accent,
|
|
647
|
+
cursor: "pointer",
|
|
648
|
+
textDecoration: "underline",
|
|
649
|
+
textDecorationColor: `${theme.accent}55`,
|
|
650
|
+
textDecorationStyle: "dotted",
|
|
651
|
+
}}
|
|
652
|
+
>
|
|
653
|
+
{file.path}
|
|
654
|
+
</span>
|
|
655
|
+
<EnvBadge env={file.environment} small />
|
|
656
|
+
</div>
|
|
657
|
+
|
|
658
|
+
<div
|
|
659
|
+
style={{
|
|
660
|
+
fontFamily: theme.sans,
|
|
661
|
+
fontSize: 12,
|
|
662
|
+
color: theme.textMuted,
|
|
663
|
+
}}
|
|
664
|
+
>
|
|
665
|
+
{message}
|
|
666
|
+
</div>
|
|
667
|
+
</div>
|
|
668
|
+
</div>
|
|
669
|
+
);
|
|
670
|
+
})}
|
|
671
|
+
</div>
|
|
672
|
+
</div>
|
|
673
|
+
);
|
|
674
|
+
})}
|
|
675
|
+
|
|
676
|
+
{/* Footer hint */}
|
|
677
|
+
{!loading && summary && summary.total_files > 0 && (
|
|
678
|
+
<div
|
|
679
|
+
style={{
|
|
680
|
+
marginTop: 8,
|
|
681
|
+
padding: "12px 16px",
|
|
682
|
+
background: theme.surface,
|
|
683
|
+
border: `1px solid ${theme.border}`,
|
|
684
|
+
borderRadius: 8,
|
|
685
|
+
display: "flex",
|
|
686
|
+
alignItems: "center",
|
|
687
|
+
gap: 12,
|
|
688
|
+
}}
|
|
689
|
+
>
|
|
690
|
+
<span style={{ fontSize: 14 }}>{"\uD83D\uDCA1"}</span>
|
|
691
|
+
<span
|
|
692
|
+
style={{
|
|
693
|
+
fontFamily: theme.sans,
|
|
694
|
+
fontSize: 12,
|
|
695
|
+
color: theme.textMuted,
|
|
696
|
+
}}
|
|
697
|
+
>
|
|
698
|
+
Edit{" "}
|
|
699
|
+
<code
|
|
700
|
+
style={{
|
|
701
|
+
fontFamily: theme.mono,
|
|
702
|
+
fontSize: 11,
|
|
703
|
+
color: theme.accent,
|
|
704
|
+
background: theme.accentDim,
|
|
705
|
+
padding: "1px 6px",
|
|
706
|
+
borderRadius: 3,
|
|
707
|
+
}}
|
|
708
|
+
>
|
|
709
|
+
.clef/policy.yaml
|
|
710
|
+
</code>{" "}
|
|
711
|
+
to change rotation limits. Run{" "}
|
|
712
|
+
<code
|
|
713
|
+
style={{
|
|
714
|
+
fontFamily: theme.mono,
|
|
715
|
+
fontSize: 11,
|
|
716
|
+
color: theme.accent,
|
|
717
|
+
background: theme.accentDim,
|
|
718
|
+
padding: "1px 6px",
|
|
719
|
+
borderRadius: 3,
|
|
720
|
+
}}
|
|
721
|
+
>
|
|
722
|
+
clef policy check
|
|
723
|
+
</code>{" "}
|
|
724
|
+
locally to reproduce this verdict.
|
|
725
|
+
</span>
|
|
726
|
+
</div>
|
|
727
|
+
)}
|
|
728
|
+
</div>
|
|
729
|
+
</div>
|
|
730
|
+
);
|
|
731
|
+
}
|