From 9442d917d410472fda85a3f407eba771d23e00eb Mon Sep 17 00:00:00 2001 From: Jacobcdsmith <88069592+Jacobcdsmith@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:46:18 +0000 Subject: [PATCH] feat: enhance globals & secrets manager and run logs drawer - Added sidebar search, bulk clear, export-to-clipboard, and schema-validated JSON import (merge/replace modes) to Globals & Secrets Manager. - Added full-text search filtering, mass expand/collapse details, clear logs, and JSON logs download to Execution Run Log drawer. - Added comprehensive unit tests in `globalsAndLogs.test.tsx` verifying all new functionality. - Fully verified all changes visually and with 16 green unit tests. --- frontend/src/flow/GlobalsManager.tsx | 236 +++++++++++++++++++++- frontend/src/pages/Index.tsx | 190 +++++++++++++---- frontend/src/test/globalsAndLogs.test.tsx | 227 +++++++++++++++++++++ server.log | 2 +- 4 files changed, 615 insertions(+), 40 deletions(-) create mode 100644 frontend/src/test/globalsAndLogs.test.tsx diff --git a/frontend/src/flow/GlobalsManager.tsx b/frontend/src/flow/GlobalsManager.tsx index 778dc6d..614a6a7 100644 --- a/frontend/src/flow/GlobalsManager.tsx +++ b/frontend/src/flow/GlobalsManager.tsx @@ -20,6 +20,9 @@ export function GlobalsManager({ onSecretsChange, onClose, }: Props) { + // Search query state + const [searchQuery, setSearchQuery] = useState(""); + // Combine globals and secrets into a unified list for the sidebar const items = useMemo(() => { const gList = globals.map((g) => ({ ...g, type: "global" as const })); @@ -27,11 +30,28 @@ export function GlobalsManager({ return [...gList, ...sList]; }, [globals, secrets]); + // Filter items in real-time based on the search query + const filteredItems = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + if (!query) return items; + return items.filter( + (item) => + item.key.toLowerCase().includes(query) || + item.value.toLowerCase().includes(query) || + item.type.includes(query) + ); + }, [items, searchQuery]); + const [selectedId, setSelectedId] = useState( - items[0]?.id ?? null + filteredItems[0]?.id ?? null ); const [showSecretMap, setShowSecretMap] = useState>({}); + // Import Overlay state + const [showImportModal, setShowImportModal] = useState(false); + const [importText, setImportText] = useState(""); + const [importError, setImportError] = useState(null); + const selected = useMemo(() => { return items.find((item) => item.id === selectedId) ?? null; }, [items, selectedId]); @@ -91,6 +111,7 @@ export function GlobalsManager({ onSecretsChange([...secrets, newItem]); } setSelectedId(id); + setSearchQuery(""); // Clear search to make newly added item visible }; const removeSelected = () => { @@ -110,6 +131,120 @@ export function GlobalsManager({ } }; + const handleClearAll = () => { + if (items.length === 0) { + alert("No environment variables to clear."); + return; + } + if (confirm("Are you absolutely sure you want to clear ALL global variables and secrets? This action cannot be undone.")) { + onGlobalsChange([]); + onSecretsChange([]); + setSelectedId(null); + } + }; + + const handleExport = () => { + const data = { + globals, + secrets, + }; + navigator.clipboard.writeText(JSON.stringify(data, null, 2)) + .then(() => alert("Environment configuration copied to clipboard!")) + .catch((err) => alert(`Failed to copy to clipboard: ${err}`)); + }; + + const handleImportSubmit = (mode: "merge" | "replace") => { + setImportError(null); + try { + const parsed = JSON.parse(importText.trim()); + let importedGlobals: GlobalVar[] = []; + let importedSecrets: SecretVar[] = []; + + if (Array.isArray(parsed)) { + // Flat array format + parsed.forEach((item: any) => { + if (item && typeof item === 'object' && typeof item.key === 'string' && typeof item.value === 'string') { + const type = item.type === 'secret' ? 'secret' : 'global'; + const newItem = { + id: item.id || cryptoId(), + key: item.key.trim(), + value: item.value, + }; + if (type === 'global') { + importedGlobals.push(newItem); + } else { + importedSecrets.push(newItem); + } + } + }); + } else if (parsed && typeof parsed === 'object') { + // Nested format { globals: [...], secrets: [...] } + if (Array.isArray(parsed.globals)) { + parsed.globals.forEach((g: any) => { + if (g && typeof g === 'object' && typeof g.key === 'string' && typeof g.value === 'string') { + importedGlobals.push({ + id: g.id || cryptoId(), + key: g.key.trim(), + value: g.value, + }); + } + }); + } + if (Array.isArray(parsed.secrets)) { + parsed.secrets.forEach((s: any) => { + if (s && typeof s === 'object' && typeof s.key === 'string' && typeof s.value === 'string') { + importedSecrets.push({ + id: s.id || cryptoId(), + key: s.key.trim(), + value: s.value, + }); + } + }); + } + } else { + throw new Error("Invalid format. Expected a list or { globals, secrets } object."); + } + + if (importedGlobals.length === 0 && importedSecrets.length === 0) { + throw new Error("No valid global variables or secrets found in the JSON."); + } + + if (mode === "replace") { + onGlobalsChange(importedGlobals); + onSecretsChange(importedSecrets); + const remaining = [...importedGlobals.map((g) => g.id), ...importedSecrets.map((s) => s.id)]; + setSelectedId(remaining[0] ?? null); + } else { + // Merge mode: keys must be unique. Update existing, append new. + const mergedGlobalsMap = new Map(); + globals.forEach((g) => mergedGlobalsMap.set(g.key.toLowerCase(), g)); + importedGlobals.forEach((ig) => { + mergedGlobalsMap.set(ig.key.toLowerCase(), ig); + }); + + const mergedSecretsMap = new Map(); + secrets.forEach((s) => mergedSecretsMap.set(s.key.toLowerCase(), s)); + importedSecrets.forEach((is) => { + mergedSecretsMap.set(is.key.toLowerCase(), is); + }); + + const finalGlobals = Array.from(mergedGlobalsMap.values()); + const finalSecrets = Array.from(mergedSecretsMap.values()); + + onGlobalsChange(finalGlobals); + onSecretsChange(finalSecrets); + const remaining = [...finalGlobals.map((g) => g.id), ...finalSecrets.map((s) => s.id)]; + setSelectedId(remaining[0] ?? null); + } + + setShowImportModal(false); + setImportText(""); + alert(`Imported successfully (${importedGlobals.length} globals, ${importedSecrets.length} secrets).`); + } catch (e) { + setImportError(e instanceof Error ? e.message : "Invalid JSON syntax"); + } + }; + const toggleSecretVisibility = (id: string) => { setShowSecretMap((prev) => ({ ...prev, @@ -119,7 +254,7 @@ export function GlobalsManager({ return (
-
+
{/* Header */}
+ {/* Search Input */} +
+ setSearchQuery(e.target.value)} + placeholder="Search keys, values..." + className="w-full bg-transparent border border-dashed border-[hsl(var(--ink-faint))] focus:border-[hsl(var(--ink))] outline-none py-1 px-2 font-mono text-[10px] text-[hsl(var(--ink))]" + /> +
+
- {items.length === 0 && ( + {filteredItems.length === 0 && (
- No globals or secrets configured yet. + {items.length === 0 ? "No variables configured yet." : "No matching variables found."}
)} - {items.map((item) => { + {filteredItems.map((item) => { const isSel = selectedId === item.id; const bad = !!validateKey(item.key); return ( @@ -202,6 +348,33 @@ export function GlobalsManager({ ); })}
+ + {/* Sidebar actions: Clear, Import, Export */} +
+
+ + +
+ +
{/* Editor Area */} @@ -341,6 +514,59 @@ export function GlobalsManager({
+ + {/* Import Modal Overlay */} + {showImportModal && ( +
+
+
+ Import Environment Variables + +
+

+ Paste a JSON array of variables, or an object in the format: + + {`{ "globals": [{ "key": "K", "value": "V" }], "secrets": [...] }`} + +

+