@clef-sh/ui 0.1.13-beta.88
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/README.md +38 -0
- package/dist/client/assets/index-CVpAmirt.js +26 -0
- package/dist/client/favicon-96x96.png +0 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/favicon.svg +16 -0
- package/dist/client/index.html +50 -0
- package/dist/client-lib/api.d.ts +3 -0
- package/dist/client-lib/api.d.ts.map +1 -0
- package/dist/client-lib/components/Button.d.ts +10 -0
- package/dist/client-lib/components/Button.d.ts.map +1 -0
- package/dist/client-lib/components/CopyButton.d.ts +6 -0
- package/dist/client-lib/components/CopyButton.d.ts.map +1 -0
- package/dist/client-lib/components/EnvBadge.d.ts +7 -0
- package/dist/client-lib/components/EnvBadge.d.ts.map +1 -0
- package/dist/client-lib/components/MatrixGrid.d.ts +13 -0
- package/dist/client-lib/components/MatrixGrid.d.ts.map +1 -0
- package/dist/client-lib/components/Sidebar.d.ts +16 -0
- package/dist/client-lib/components/Sidebar.d.ts.map +1 -0
- package/dist/client-lib/components/StatusDot.d.ts +6 -0
- package/dist/client-lib/components/StatusDot.d.ts.map +1 -0
- package/dist/client-lib/components/TopBar.d.ts +9 -0
- package/dist/client-lib/components/TopBar.d.ts.map +1 -0
- package/dist/client-lib/index.d.ts +12 -0
- package/dist/client-lib/index.d.ts.map +1 -0
- package/dist/client-lib/theme.d.ts +42 -0
- package/dist/client-lib/theme.d.ts.map +1 -0
- package/dist/server/api.d.ts +11 -0
- package/dist/server/api.d.ts.map +1 -0
- package/dist/server/api.js +1020 -0
- package/dist/server/api.js.map +1 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +231 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +74 -0
- package/src/client/App.tsx +205 -0
- package/src/client/api.test.tsx +94 -0
- package/src/client/api.ts +30 -0
- package/src/client/components/Button.tsx +52 -0
- package/src/client/components/CopyButton.test.tsx +43 -0
- package/src/client/components/CopyButton.tsx +36 -0
- package/src/client/components/EnvBadge.tsx +32 -0
- package/src/client/components/MatrixGrid.tsx +265 -0
- package/src/client/components/Sidebar.tsx +337 -0
- package/src/client/components/StatusDot.tsx +30 -0
- package/src/client/components/TopBar.tsx +50 -0
- package/src/client/index.html +50 -0
- package/src/client/index.ts +18 -0
- package/src/client/main.tsx +15 -0
- package/src/client/public/favicon-96x96.png +0 -0
- package/src/client/public/favicon.ico +0 -0
- package/src/client/public/favicon.svg +16 -0
- package/src/client/screens/BackendScreen.test.tsx +611 -0
- package/src/client/screens/BackendScreen.tsx +836 -0
- package/src/client/screens/DiffView.test.tsx +130 -0
- package/src/client/screens/DiffView.tsx +547 -0
- package/src/client/screens/GitLogView.test.tsx +113 -0
- package/src/client/screens/GitLogView.tsx +192 -0
- package/src/client/screens/ImportScreen.tsx +710 -0
- package/src/client/screens/LintView.test.tsx +143 -0
- package/src/client/screens/LintView.tsx +589 -0
- package/src/client/screens/MatrixView.test.tsx +138 -0
- package/src/client/screens/MatrixView.tsx +143 -0
- package/src/client/screens/NamespaceEditor.test.tsx +694 -0
- package/src/client/screens/NamespaceEditor.tsx +1122 -0
- package/src/client/screens/RecipientsScreen.tsx +696 -0
- package/src/client/screens/ScanScreen.test.tsx +323 -0
- package/src/client/screens/ScanScreen.tsx +523 -0
- package/src/client/screens/ServiceIdentitiesScreen.tsx +1398 -0
- package/src/client/theme.ts +48 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for client/api.ts — session token management and apiFetch wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Each test gets a fresh module instance via jest.resetModules() so the
|
|
5
|
+
* module-level `sessionToken` variable always starts as null.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
describe("client/api module", () => {
|
|
9
|
+
let mod: typeof import("./api");
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
// Reset module registry so sessionToken starts as null for every test
|
|
13
|
+
jest.resetModules();
|
|
14
|
+
// Reset URL to a clean baseline
|
|
15
|
+
window.history.replaceState({}, "", "/");
|
|
16
|
+
// Clear sessionStorage so token state is clean
|
|
17
|
+
sessionStorage.clear();
|
|
18
|
+
// Get a fresh module
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
20
|
+
mod = require("./api") as typeof import("./api");
|
|
21
|
+
// Stub global fetch (jsdom does not provide a Response global)
|
|
22
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
23
|
+
ok: true,
|
|
24
|
+
json: jest.fn().mockResolvedValue({}),
|
|
25
|
+
} as unknown as Response);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
jest.restoreAllMocks();
|
|
30
|
+
sessionStorage.clear();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("initToken()", () => {
|
|
34
|
+
it("should do nothing when the URL has no token parameter", () => {
|
|
35
|
+
// No ?token= in the URL — should not throw and should leave sessionToken null
|
|
36
|
+
mod.initToken();
|
|
37
|
+
mod.apiFetch("/api/test");
|
|
38
|
+
const [, callInit] = (global.fetch as jest.Mock).mock.calls[0];
|
|
39
|
+
const headers = callInit.headers as Headers;
|
|
40
|
+
expect(headers.get("Authorization")).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should extract the token from the URL query string", () => {
|
|
44
|
+
window.history.pushState({}, "", "/?token=abc123");
|
|
45
|
+
mod.initToken();
|
|
46
|
+
// After initToken the token should be forwarded in apiFetch
|
|
47
|
+
mod.apiFetch("/api/test");
|
|
48
|
+
const [, callInit] = (global.fetch as jest.Mock).mock.calls[0];
|
|
49
|
+
const headers = callInit.headers as Headers;
|
|
50
|
+
expect(headers.get("Authorization")).toBe("Bearer abc123");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should remove the token from the URL after extracting it", () => {
|
|
54
|
+
window.history.pushState({}, "", "/?token=abc123");
|
|
55
|
+
const replaceSpy = jest.spyOn(window.history, "replaceState");
|
|
56
|
+
mod.initToken();
|
|
57
|
+
expect(replaceSpy).toHaveBeenCalled();
|
|
58
|
+
expect(window.location.search).not.toContain("token=");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should restore the token from sessionStorage on refresh (no URL token)", () => {
|
|
62
|
+
// Simulate a previous visit that saved the token
|
|
63
|
+
sessionStorage.setItem("clef_ui_token", "stored-token");
|
|
64
|
+
// URL has no token (simulates a browser refresh)
|
|
65
|
+
mod.initToken();
|
|
66
|
+
mod.apiFetch("/api/test");
|
|
67
|
+
const [, callInit] = (global.fetch as jest.Mock).mock.calls[0];
|
|
68
|
+
const headers = callInit.headers as Headers;
|
|
69
|
+
expect(headers.get("Authorization")).toBe("Bearer stored-token");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should persist the token to sessionStorage when extracted from URL", () => {
|
|
73
|
+
window.history.pushState({}, "", "/?token=abc123");
|
|
74
|
+
mod.initToken();
|
|
75
|
+
expect(sessionStorage.getItem("clef_ui_token")).toBe("abc123");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("apiFetch()", () => {
|
|
80
|
+
it("should call fetch without an Authorization header when no token is set", () => {
|
|
81
|
+
mod.apiFetch("/api/manifest");
|
|
82
|
+
const [, callInit] = (global.fetch as jest.Mock).mock.calls[0];
|
|
83
|
+
const headers = callInit.headers as Headers;
|
|
84
|
+
expect(headers.get("Authorization")).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should forward extra init options to fetch", () => {
|
|
88
|
+
mod.apiFetch("/api/test", { method: "POST", body: JSON.stringify({ x: 1 }) });
|
|
89
|
+
const [url, callInit] = (global.fetch as jest.Mock).mock.calls[0];
|
|
90
|
+
expect(url).toBe("/api/test");
|
|
91
|
+
expect(callInit.method).toBe("POST");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Session token — extracted from URL query parameter on initial page load.
|
|
2
|
+
// Persisted in sessionStorage so it survives same-tab refreshes but is
|
|
3
|
+
// cleared when the tab closes (never written to localStorage).
|
|
4
|
+
const SESSION_KEY = "clef_ui_token";
|
|
5
|
+
|
|
6
|
+
let sessionToken: string | null = null;
|
|
7
|
+
|
|
8
|
+
export function initToken(): void {
|
|
9
|
+
const params = new URLSearchParams(window.location.search);
|
|
10
|
+
const token = params.get("token");
|
|
11
|
+
if (token) {
|
|
12
|
+
sessionToken = token;
|
|
13
|
+
sessionStorage.setItem(SESSION_KEY, token);
|
|
14
|
+
// Remove token from URL to avoid leaking it in browser history
|
|
15
|
+
const url = new URL(window.location.href);
|
|
16
|
+
url.searchParams.delete("token");
|
|
17
|
+
window.history.replaceState({}, "", url.pathname + url.hash);
|
|
18
|
+
} else {
|
|
19
|
+
// Restore from sessionStorage on refresh (token no longer in URL)
|
|
20
|
+
sessionToken = sessionStorage.getItem(SESSION_KEY);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function apiFetch(path: string, init?: RequestInit): Promise<Response> {
|
|
25
|
+
const headers = new Headers(init?.headers);
|
|
26
|
+
if (sessionToken) {
|
|
27
|
+
headers.set("Authorization", `Bearer ${sessionToken}`);
|
|
28
|
+
}
|
|
29
|
+
return fetch(path, { ...init, headers });
|
|
30
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { theme } from "../theme";
|
|
3
|
+
|
|
4
|
+
interface ButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "type"> {
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
variant?: "primary" | "ghost" | "danger";
|
|
7
|
+
icon?: React.ReactNode;
|
|
8
|
+
type?: "button" | "submit";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const VARIANT_STYLES = {
|
|
12
|
+
primary: { bg: theme.accent, color: "#000", border: "none" },
|
|
13
|
+
ghost: { bg: "transparent", color: theme.text, border: `1px solid ${theme.borderLight}` },
|
|
14
|
+
danger: { bg: theme.redDim, color: theme.red, border: `1px solid ${theme.red}44` },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function Button({
|
|
18
|
+
children,
|
|
19
|
+
variant = "ghost",
|
|
20
|
+
onClick,
|
|
21
|
+
icon,
|
|
22
|
+
type = "button",
|
|
23
|
+
style: _styleProp,
|
|
24
|
+
...rest
|
|
25
|
+
}: ButtonProps) {
|
|
26
|
+
const s = VARIANT_STYLES[variant];
|
|
27
|
+
return (
|
|
28
|
+
<button
|
|
29
|
+
type={type}
|
|
30
|
+
onClick={onClick}
|
|
31
|
+
{...rest}
|
|
32
|
+
style={{
|
|
33
|
+
display: "flex",
|
|
34
|
+
alignItems: "center",
|
|
35
|
+
gap: 6,
|
|
36
|
+
padding: "5px 12px",
|
|
37
|
+
borderRadius: 6,
|
|
38
|
+
cursor: "pointer",
|
|
39
|
+
fontFamily: theme.sans,
|
|
40
|
+
fontSize: 12,
|
|
41
|
+
fontWeight: 600,
|
|
42
|
+
background: s.bg,
|
|
43
|
+
color: s.color,
|
|
44
|
+
border: s.border,
|
|
45
|
+
transition: "all 0.12s",
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
{icon && <span style={{ display: "flex" }}>{icon}</span>}
|
|
49
|
+
{children}
|
|
50
|
+
</button>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, fireEvent, act } from "@testing-library/react";
|
|
3
|
+
import "@testing-library/jest-dom";
|
|
4
|
+
import { CopyButton } from "./CopyButton";
|
|
5
|
+
|
|
6
|
+
describe("CopyButton", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
9
|
+
value: { writeText: jest.fn().mockResolvedValue(undefined) },
|
|
10
|
+
writable: true,
|
|
11
|
+
configurable: true,
|
|
12
|
+
});
|
|
13
|
+
jest.useFakeTimers();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
jest.useRealTimers();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("renders with 'copy' label by default", () => {
|
|
21
|
+
render(<CopyButton text="hello world" />);
|
|
22
|
+
expect(screen.getByTestId("copy-button")).toBeInTheDocument();
|
|
23
|
+
expect(screen.getByTestId("copy-button")).toHaveTextContent("copy");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("shows 'copied!' after clicking and reverts after timeout", async () => {
|
|
27
|
+
render(<CopyButton text="hello world" />);
|
|
28
|
+
const button = screen.getByTestId("copy-button");
|
|
29
|
+
|
|
30
|
+
act(() => {
|
|
31
|
+
fireEvent.click(button);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(button).toHaveTextContent("copied!");
|
|
35
|
+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello world");
|
|
36
|
+
|
|
37
|
+
act(() => {
|
|
38
|
+
jest.advanceTimersByTime(1800);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(button).toHaveTextContent("copy");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React, { useState, useCallback } from "react";
|
|
2
|
+
import { theme } from "../theme";
|
|
3
|
+
|
|
4
|
+
interface CopyButtonProps {
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function CopyButton({ text }: CopyButtonProps) {
|
|
9
|
+
const [copied, setCopied] = useState(false);
|
|
10
|
+
|
|
11
|
+
const handleCopy = useCallback(() => {
|
|
12
|
+
navigator.clipboard?.writeText(text);
|
|
13
|
+
setCopied(true);
|
|
14
|
+
setTimeout(() => setCopied(false), 1800);
|
|
15
|
+
}, [text]);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<button
|
|
19
|
+
data-testid="copy-button"
|
|
20
|
+
onClick={handleCopy}
|
|
21
|
+
style={{
|
|
22
|
+
background: copied ? theme.greenDim : "none",
|
|
23
|
+
border: `1px solid ${copied ? theme.green + "55" : theme.borderLight}`,
|
|
24
|
+
borderRadius: 4,
|
|
25
|
+
cursor: "pointer",
|
|
26
|
+
color: copied ? theme.green : theme.textDim,
|
|
27
|
+
fontFamily: theme.mono,
|
|
28
|
+
fontSize: 10,
|
|
29
|
+
padding: "2px 8px",
|
|
30
|
+
transition: "all 0.15s",
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
{copied ? "copied!" : "copy"}
|
|
34
|
+
</button>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { theme, ENV_COLORS } from "../theme";
|
|
3
|
+
|
|
4
|
+
interface EnvBadgeProps {
|
|
5
|
+
env: string;
|
|
6
|
+
small?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function EnvBadge({ env, small }: EnvBadgeProps) {
|
|
10
|
+
const c = ENV_COLORS[env] ?? {
|
|
11
|
+
color: theme.textMuted,
|
|
12
|
+
bg: "transparent",
|
|
13
|
+
label: env.toUpperCase().slice(0, 3),
|
|
14
|
+
};
|
|
15
|
+
return (
|
|
16
|
+
<span
|
|
17
|
+
style={{
|
|
18
|
+
fontFamily: theme.mono,
|
|
19
|
+
fontSize: small ? "9px" : "10px",
|
|
20
|
+
fontWeight: 700,
|
|
21
|
+
color: c.color,
|
|
22
|
+
background: c.bg,
|
|
23
|
+
border: `1px solid ${c.color}33`,
|
|
24
|
+
borderRadius: "3px",
|
|
25
|
+
padding: small ? "1px 5px" : "2px 7px",
|
|
26
|
+
letterSpacing: "0.08em",
|
|
27
|
+
}}
|
|
28
|
+
>
|
|
29
|
+
{c.label}
|
|
30
|
+
</span>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { theme } from "../theme";
|
|
3
|
+
import { EnvBadge } from "./EnvBadge";
|
|
4
|
+
import { StatusDot } from "./StatusDot";
|
|
5
|
+
import type { MatrixStatus } from "@clef-sh/core";
|
|
6
|
+
|
|
7
|
+
export interface MatrixGridProps {
|
|
8
|
+
namespaces: Array<{ name: string }>;
|
|
9
|
+
environments: Array<{ name: string }>;
|
|
10
|
+
matrixStatuses: MatrixStatus[];
|
|
11
|
+
onNamespaceClick?: (ns: string) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getStatusType(status: MatrixStatus): string {
|
|
15
|
+
if (!status.cell.exists) return "missing_keys";
|
|
16
|
+
const hasError = status.issues.some((i) => i.type === "missing_keys" || i.type === "sops_error");
|
|
17
|
+
const hasWarning = status.issues.some((i) => i.type === "schema_warning");
|
|
18
|
+
if (hasError) return "missing_keys";
|
|
19
|
+
if (hasWarning) return "schema_warn";
|
|
20
|
+
return "ok";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatDate(d: Date | null): string {
|
|
24
|
+
if (!d) return "never";
|
|
25
|
+
const now = new Date();
|
|
26
|
+
const diffMs = now.getTime() - d.getTime();
|
|
27
|
+
const diffH = Math.floor(diffMs / (1000 * 60 * 60));
|
|
28
|
+
if (diffH < 1) return "just now";
|
|
29
|
+
if (diffH < 24) return `${diffH}h ago`;
|
|
30
|
+
const diffD = Math.floor(diffH / 24);
|
|
31
|
+
if (diffD < 7) return `${diffD}d ago`;
|
|
32
|
+
const diffW = Math.floor(diffD / 7);
|
|
33
|
+
return `${diffW}w ago`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function MatrixGrid({
|
|
37
|
+
namespaces,
|
|
38
|
+
environments,
|
|
39
|
+
matrixStatuses,
|
|
40
|
+
onNamespaceClick,
|
|
41
|
+
}: MatrixGridProps) {
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
data-testid="matrix-table"
|
|
45
|
+
style={{
|
|
46
|
+
background: theme.surface,
|
|
47
|
+
border: `1px solid ${theme.border}`,
|
|
48
|
+
borderRadius: 12,
|
|
49
|
+
overflow: "hidden",
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
{/* Header row */}
|
|
53
|
+
<div
|
|
54
|
+
style={{
|
|
55
|
+
display: "grid",
|
|
56
|
+
gridTemplateColumns: `180px ${environments.map(() => "1fr").join(" ")}`,
|
|
57
|
+
borderBottom: `1px solid ${theme.border}`,
|
|
58
|
+
background: "#0D0F14",
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
<div
|
|
62
|
+
style={{
|
|
63
|
+
padding: "12px 20px",
|
|
64
|
+
fontFamily: theme.sans,
|
|
65
|
+
fontSize: 11,
|
|
66
|
+
fontWeight: 600,
|
|
67
|
+
color: theme.textMuted,
|
|
68
|
+
textTransform: "uppercase",
|
|
69
|
+
letterSpacing: "0.08em",
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
Namespace
|
|
73
|
+
</div>
|
|
74
|
+
{environments.map((env) => (
|
|
75
|
+
<div
|
|
76
|
+
key={env.name}
|
|
77
|
+
style={{
|
|
78
|
+
padding: "12px 20px",
|
|
79
|
+
display: "flex",
|
|
80
|
+
alignItems: "center",
|
|
81
|
+
gap: 8,
|
|
82
|
+
borderLeft: `1px solid ${theme.border}`,
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
<EnvBadge env={env.name} />
|
|
86
|
+
<span
|
|
87
|
+
style={{
|
|
88
|
+
fontFamily: theme.sans,
|
|
89
|
+
fontSize: 12,
|
|
90
|
+
fontWeight: 500,
|
|
91
|
+
color: theme.text,
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
{env.name}
|
|
95
|
+
</span>
|
|
96
|
+
</div>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
{/* Namespace rows */}
|
|
101
|
+
{namespaces.map((ns, i) => (
|
|
102
|
+
<div
|
|
103
|
+
key={ns.name}
|
|
104
|
+
data-testid={`matrix-row-${ns.name}`}
|
|
105
|
+
role="button"
|
|
106
|
+
tabIndex={0}
|
|
107
|
+
onClick={() => onNamespaceClick?.(ns.name)}
|
|
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 */}
|
|
126
|
+
<div
|
|
127
|
+
style={{
|
|
128
|
+
padding: "16px 20px",
|
|
129
|
+
display: "flex",
|
|
130
|
+
alignItems: "center",
|
|
131
|
+
gap: 10,
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
<span
|
|
135
|
+
style={{
|
|
136
|
+
fontFamily: theme.mono,
|
|
137
|
+
fontSize: 11,
|
|
138
|
+
color: theme.textDim,
|
|
139
|
+
}}
|
|
140
|
+
>
|
|
141
|
+
//
|
|
142
|
+
</span>
|
|
143
|
+
<span
|
|
144
|
+
style={{
|
|
145
|
+
fontFamily: theme.mono,
|
|
146
|
+
fontSize: 13,
|
|
147
|
+
fontWeight: 600,
|
|
148
|
+
color: theme.text,
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
{ns.name}
|
|
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}
|
|
184
|
+
style={{
|
|
185
|
+
padding: "14px 20px",
|
|
186
|
+
borderLeft: `1px solid ${theme.border}`,
|
|
187
|
+
display: "flex",
|
|
188
|
+
flexDirection: "column",
|
|
189
|
+
gap: 5,
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
|
|
193
|
+
<StatusDot status={statusType} />
|
|
194
|
+
<span
|
|
195
|
+
style={{
|
|
196
|
+
fontFamily: theme.mono,
|
|
197
|
+
fontSize: 11,
|
|
198
|
+
color: theme.textMuted,
|
|
199
|
+
}}
|
|
200
|
+
>
|
|
201
|
+
{keyCount} keys
|
|
202
|
+
</span>
|
|
203
|
+
{missingKeyCount > 0 && (
|
|
204
|
+
<span
|
|
205
|
+
style={{
|
|
206
|
+
fontFamily: theme.mono,
|
|
207
|
+
fontSize: 10,
|
|
208
|
+
color: theme.red,
|
|
209
|
+
background: theme.redDim,
|
|
210
|
+
border: `1px solid ${theme.red}33`,
|
|
211
|
+
borderRadius: 3,
|
|
212
|
+
padding: "1px 5px",
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
-{missingKeyCount} missing
|
|
216
|
+
</span>
|
|
217
|
+
)}
|
|
218
|
+
{warnKeyCount > 0 && (
|
|
219
|
+
<span
|
|
220
|
+
style={{
|
|
221
|
+
fontFamily: theme.mono,
|
|
222
|
+
fontSize: 10,
|
|
223
|
+
color: theme.yellow,
|
|
224
|
+
background: theme.yellowDim,
|
|
225
|
+
border: `1px solid ${theme.yellow}33`,
|
|
226
|
+
borderRadius: 3,
|
|
227
|
+
padding: "1px 5px",
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
{warnKeyCount} warn
|
|
231
|
+
</span>
|
|
232
|
+
)}
|
|
233
|
+
{cellPending > 0 && (
|
|
234
|
+
<span
|
|
235
|
+
style={{
|
|
236
|
+
fontFamily: theme.mono,
|
|
237
|
+
fontSize: 10,
|
|
238
|
+
color: theme.accent,
|
|
239
|
+
background: `${theme.accent}18`,
|
|
240
|
+
border: `1px solid ${theme.accent}33`,
|
|
241
|
+
borderRadius: 3,
|
|
242
|
+
padding: "1px 5px",
|
|
243
|
+
}}
|
|
244
|
+
>
|
|
245
|
+
{cellPending} pending
|
|
246
|
+
</span>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
<div
|
|
250
|
+
style={{
|
|
251
|
+
fontFamily: theme.mono,
|
|
252
|
+
fontSize: 10,
|
|
253
|
+
color: theme.textDim,
|
|
254
|
+
}}
|
|
255
|
+
>
|
|
256
|
+
{lastMod}
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
})}
|
|
261
|
+
</div>
|
|
262
|
+
))}
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
}
|