window.KubeViz = { graph: { nodes: [], edges: [], groups: [], findings: [] }, positions: {}, selectedId: null, lastParsedResources: 0, view: { scale: 1, minScale: 0.4, maxScale: 3.0, panX: 0, panY: 0, }, dragging: { nodeId: null, panning: false, panStartX: 0, panStartY: 0, }, refreshTimer: null, search: { suggestions: [], activeIndex: -1, }, path: { nodes: new Set(), edges: new Set(), ids: [], }, pathSuggest: { source: { items: [], index: -1 }, target: { items: [], index: -1 }, }, }; const THEME_KEY = "kubeviz-theme"; const THEME_ORDER = ["system", "light", "dark"]; const SAVED_VIEWS_KEY = "kubeviz-saved-views"; document.addEventListener("DOMContentLoaded", () => { initTheme(); initTabs(); initModal(); const parseForm = document.getElementById("manifest-form"); const filterForm = document.getElementById("filter-form"); const clearBtn = document.getElementById("clear-session"); const resetBtn = document.getElementById("reset-view"); const clearInlineBtn = document.getElementById("clear-filters-inline"); const openDataTabBtn = document.getElementById("open-data-tab"); const openFilterTabBtn = document.getElementById("open-filter-tab"); const fileInput = document.getElementById("manifestFile"); const filePickerBtn = document.getElementById("file-picker-btn"); const fileCount = document.getElementById("file-count"); const zoomInBtn = document.getElementById("zoom-in"); const zoomOutBtn = document.getElementById("zoom-out"); const zoomResetBtn = document.getElementById("zoom-reset"); const runDiffBtn = document.getElementById("run-diff"); const jumpBtn = document.getElementById("jump-to-search"); const queryInput = document.getElementById("query"); const layoutMode = document.getElementById("layout-mode"); const resetChecksBtn = document.getElementById("reset-checks"); const saveViewBtn = document.getElementById("save-view"); const loadViewBtn = document.getElementById("load-view"); const deleteViewBtn = document.getElementById("delete-view"); const exportViewsBtn = document.getElementById("export-views"); const importViewsBtn = document.getElementById("import-views"); const importViewsFile = document.getElementById("import-views-file"); const runPathBtn = document.getElementById("run-path"); const clearPathBtn = document.getElementById("clear-path"); const pathSourceInput = document.getElementById("path-source"); const pathTargetInput = document.getElementById("path-target"); const minimap = document.getElementById("minimap-canvas"); const gitImportManifestsBtn = document.getElementById("git-import-manifests"); const gitImportHelmBtn = document.getElementById("git-import-helm"); filePickerBtn.addEventListener("click", () => fileInput.click()); fileInput.addEventListener("change", () => { const count = fileInput.files ? fileInput.files.length : 0; if (count === 0) { fileCount.textContent = "No files selected"; return; } fileCount.textContent = count === 1 ? fileInput.files[0].name : `${count} files selected`; }); parseForm.addEventListener("htmx:afterRequest", async (event) => { const xhr = event.detail.xhr; if (xhr.status < 200 || xhr.status >= 300) { const detail = (xhr.responseText || "").trim(); const suffix = detail ? ` ${detail}` : " Check YAML syntax and try again."; setStatus(`Parse failed (${xhr.status}).${suffix}`, "error"); renderParseIssues([]); return; } const payload = JSON.parse(xhr.responseText); const parseIssues = payload.summary?.issues || []; const issues = parseIssues.length; window.KubeViz.lastParsedResources = payload.summary?.resources || 0; setStatus(`Parsed ${window.KubeViz.lastParsedResources} resources (${formatIssueCount(issues)}).`, issues > 0 ? "warn" : "ok"); renderParseIssues(parseIssues); await fetchAndRenderGraph({ autoNamespace: true, resetLayout: true }); }); filterForm.addEventListener("submit", (event) => event.preventDefault()); bindLiveFilterEvents(); layoutMode.addEventListener("change", () => { window.KubeViz.positions = {}; renderGraph(); }); jumpBtn.addEventListener("click", () => jumpToSearchMatch()); queryInput.addEventListener("keydown", onSearchKeydown); queryInput.addEventListener("blur", () => { window.setTimeout(() => hideSearchSuggestions(), 100); }); clearBtn.addEventListener("click", async () => { await fetch("/api/session/clear", { method: "POST" }); setStatus("Session cleared. Load manifests to build a graph.", "info"); renderParseIssues([]); window.KubeViz.graph = { nodes: [], edges: [], groups: [], findings: [] }; window.KubeViz.positions = {}; window.KubeViz.selectedId = null; window.KubeViz.lastParsedResources = 0; window.KubeViz.path.nodes = new Set(); window.KubeViz.path.edges = new Set(); window.KubeViz.path.ids = []; window.KubeViz.pathSuggest.source = { items: [], index: -1 }; window.KubeViz.pathSuggest.target = { items: [], index: -1 }; document.getElementById("namespace").value = ""; document.getElementById("kind").value = ""; document.getElementById("focus").value = ""; document.getElementById("query").value = ""; document.getElementById("groupBy").value = ""; document.getElementById("path-source").value = ""; document.getElementById("path-target").value = ""; document.getElementById("path-bidirectional").checked = false; setPathResult("No path calculated."); hidePathSuggestions("source"); hidePathSuggestions("target"); document.getElementById("selected-kind-name").textContent = "none"; fileCount.textContent = "No files selected"; setSelectedResource(null); updateStats({ totalNodes: 0, totalEdges: 0 }); renderFindings([]); renderCollapseControls([]); renderActiveFilters(); renderGraph(); }); resetBtn.addEventListener("click", async () => { resetFiltersToDefault(); await fetchAndRenderGraph({ autoNamespace: true, resetLayout: true }); }); clearInlineBtn.addEventListener("click", async () => { resetFiltersToDefault(); await fetchAndRenderGraph({ autoNamespace: false, resetLayout: true }); }); if (openDataTabBtn) openDataTabBtn.addEventListener("click", () => activateTab("tab-data")); if (openFilterTabBtn) openFilterTabBtn.addEventListener("click", () => activateTab("tab-filter")); zoomInBtn.addEventListener("click", () => setZoom(window.KubeViz.view.scale * 1.15)); zoomOutBtn.addEventListener("click", () => setZoom(window.KubeViz.view.scale / 1.15)); zoomResetBtn.addEventListener("click", resetZoom); if (saveViewBtn) saveViewBtn.addEventListener("click", saveCurrentView); if (loadViewBtn) loadViewBtn.addEventListener("click", loadSelectedView); if (deleteViewBtn) deleteViewBtn.addEventListener("click", deleteSelectedView); if (exportViewsBtn) exportViewsBtn.addEventListener("click", exportSavedViews); if (importViewsBtn) importViewsBtn.addEventListener("click", () => { if (importViewsFile) importViewsFile.click(); }); if (importViewsFile) importViewsFile.addEventListener("change", importSavedViewsFromFile); if (runPathBtn) runPathBtn.addEventListener("click", runPathFinder); if (clearPathBtn) clearPathBtn.addEventListener("click", clearPathHighlight); if (pathSourceInput) bindPathSuggestInput(pathSourceInput, "source"); if (pathTargetInput) bindPathSuggestInput(pathTargetInput, "target"); if (minimap) initMinimap(minimap); if (gitImportManifestsBtn) gitImportManifestsBtn.addEventListener("click", () => importFromGit("manifests")); if (gitImportHelmBtn) gitImportHelmBtn.addEventListener("click", () => importFromGit("helm")); runDiffBtn.addEventListener("click", runDiff); if (resetChecksBtn) { resetChecksBtn.addEventListener("click", () => { document.querySelectorAll('input[name="checkRules"]').forEach((el) => { el.checked = true; }); triggerGraphRefresh(); }); } renderSavedViews(); setStatus("Paste YAML or upload files, then click Parse.", "info"); renderParseIssues([]); fetchAndRenderGraph({ autoNamespace: true, resetLayout: true }); }); function initTabs() { const buttons = Array.from(document.querySelectorAll(".tab-btn")); for (const btn of buttons) { btn.addEventListener("click", () => { activateTab(btn.dataset.tab); }); } } function activateTab(tabId) { const buttons = Array.from(document.querySelectorAll(".tab-btn")); const panes = Array.from(document.querySelectorAll(".tab-pane")); for (const b of buttons) b.classList.toggle("active", b.dataset.tab === tabId); for (const p of panes) p.classList.toggle("active", p.id === tabId); } function initTheme() { const button = document.getElementById("theme-toggle"); if (!button) return; const saved = localStorage.getItem(THEME_KEY); const pref = THEME_ORDER.includes(saved) ? saved : "system"; applyTheme(pref); button.addEventListener("click", () => { const current = document.documentElement.dataset.themePreference || "system"; const idx = THEME_ORDER.indexOf(current); const next = THEME_ORDER[(idx + 1) % THEME_ORDER.length]; applyTheme(next); }); } function applyTheme(preference) { const html = document.documentElement; const button = document.getElementById("theme-toggle"); const pref = THEME_ORDER.includes(preference) ? preference : "system"; html.dataset.themePreference = pref; if (pref === "system") { delete html.dataset.theme; } else { html.dataset.theme = pref; } localStorage.setItem(THEME_KEY, pref); if (button) button.textContent = `Theme: ${capitalize(pref)}`; renderGraph(); } function capitalize(value) { return value.charAt(0).toUpperCase() + value.slice(1); } function initModal() { const modal = document.getElementById("diff-modal"); const openBtn = document.getElementById("open-diff"); const closeBtn = document.getElementById("close-diff"); const checksModal = document.getElementById("checks-modal"); const openChecksBtn = document.getElementById("open-checks"); const closeChecksBtn = document.getElementById("close-checks"); // Defensive init: modal must never start opened. modal.classList.add("hidden"); openBtn.addEventListener("click", () => modal.classList.remove("hidden")); closeBtn.addEventListener("click", () => modal.classList.add("hidden")); if (openChecksBtn) openChecksBtn.addEventListener("click", () => checksModal.classList.remove("hidden")); if (closeChecksBtn) closeChecksBtn.addEventListener("click", () => checksModal.classList.add("hidden")); modal.addEventListener("click", (event) => { if (event.target === modal) { modal.classList.add("hidden"); } }); if (checksModal) { checksModal.addEventListener("click", (event) => { if (event.target === checksModal) { checksModal.classList.add("hidden"); } }); } document.addEventListener("keydown", (event) => { if (event.key === "Escape") { modal.classList.add("hidden"); if (checksModal) checksModal.classList.add("hidden"); hideSearchSuggestions(); hidePathSuggestions("source"); hidePathSuggestions("target"); } }); document.addEventListener("keydown", (event) => { if (isTypingContext(event.target)) return; if (event.metaKey || event.ctrlKey || event.altKey) return; if (event.key === "/") { event.preventDefault(); activateTab("tab-filter"); const query = document.getElementById("query"); if (query) query.focus(); return; } if (event.key.toLowerCase() === "d") { event.preventDefault(); modal.classList.remove("hidden"); return; } if (event.key.toLowerCase() === "c" && checksModal) { event.preventDefault(); checksModal.classList.remove("hidden"); } }); } function bindLiveFilterEvents() { const selectors = [ "#query", "#namespace", "#kind", "#focus", "#groupBy", 'input[name="relations"]', 'input[name="checkRules"]', ]; for (const selector of selectors) { const elements = document.querySelectorAll(selector); elements.forEach((el) => { const eventName = el.tagName === "INPUT" && el.type === "text" ? "input" : "change"; el.addEventListener(eventName, () => { if (el.id === "query") updateSearchSuggestions(); triggerGraphRefresh(); }); }); } } function triggerGraphRefresh() { if (window.KubeViz.refreshTimer) { window.clearTimeout(window.KubeViz.refreshTimer); } window.KubeViz.refreshTimer = window.setTimeout(() => { fetchAndRenderGraph({ autoNamespace: false, resetLayout: false }); }, 180); } function getFilterParams() { const params = new URLSearchParams(); const namespace = document.getElementById("namespace").value.trim(); const kind = document.getElementById("kind").value.trim(); const q = document.getElementById("query").value.trim(); const focus = document.getElementById("focus").value.trim(); const groupBy = document.getElementById("groupBy").value; if (namespace) params.set("namespace", namespace); if (kind) params.set("kind", kind); if (q) params.set("q", q); if (focus) params.set("focus", focus); if (groupBy) params.set("groupBy", groupBy); const rels = []; document.querySelectorAll('input[name="relations"]:checked').forEach((el) => rels.push(el.value)); if (rels.length) params.set("relations", rels.join(",")); const checkRules = []; document.querySelectorAll('input[name="checkRules"]:checked').forEach((el) => checkRules.push(el.value)); params.set("checkRules", checkRules.length ? checkRules.join(",") : "none"); const collapsed = []; document.querySelectorAll('#collapse-list input[type="checkbox"][data-group-key]').forEach((el) => { if (el.checked) collapsed.push(el.dataset.groupKey); }); if (collapsed.length) params.set("collapsed", collapsed.join(",")); return params; } async function fetchAndRenderGraph(options = { autoNamespace: false, resetLayout: false }) { const params = getFilterParams(); const res = await fetch(`/api/graph?${params.toString()}`); if (!res.ok) { setStatus("Failed to load graph. Please retry parsing or reset filters.", "error"); return; } const payload = await res.json(); const prevPositions = window.KubeViz.positions || {}; window.KubeViz.graph = payload; window.KubeViz.positions = options.resetLayout ? {} : keepKnownPositions(payload.nodes || [], prevPositions); if (options.autoNamespace) applyNamespacePrefill(payload.nodes || []); updateStats(payload.stats || { totalNodes: 0, totalEdges: 0 }); updateExportLinks(getFilterParams()); renderFindings(payload.findings || []); renderCollapseControls(payload.groups || []); renderActiveFilters(); updateSearchSuggestions(); updatePathSuggestions("source"); updatePathSuggestions("target"); renderGraph(); } function keepKnownPositions(nodes, prevPositions) { const out = {}; for (const node of nodes) { if (prevPositions[node.id]) out[node.id] = prevPositions[node.id]; } return out; } function applyNamespacePrefill(nodes) { const namespaceInput = document.getElementById("namespace"); if (namespaceInput.value.trim() !== "") return; const namespaces = new Set(); for (const node of nodes) { if (node.namespace) namespaces.add(node.namespace); } if (namespaces.size === 1) namespaceInput.value = [...namespaces][0]; } function renderCollapseControls(groups) { const wrapper = document.getElementById("collapse-controls"); const list = document.getElementById("collapse-list"); list.innerHTML = ""; if (!groups.length) { wrapper.classList.add("hidden"); return; } wrapper.classList.remove("hidden"); for (const group of groups) { const row = document.createElement("label"); const cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = !!group.collapsed; cb.dataset.groupKey = group.key; cb.addEventListener("change", () => fetchAndRenderGraph({ autoNamespace: false, resetLayout: true })); const text = document.createElement("span"); text.textContent = `${group.label} (${group.count})`; row.appendChild(cb); row.appendChild(text); list.appendChild(row); } } function renderActiveFilters() { const box = document.getElementById("active-filters"); box.innerHTML = ""; const chips = []; const q = document.getElementById("query").value.trim(); const ns = document.getElementById("namespace").value.trim(); const kind = document.getElementById("kind").value.trim(); const focus = document.getElementById("focus").value.trim(); const groupBy = document.getElementById("groupBy").value; if (q) chips.push(`q:${q}`); if (ns) chips.push(`ns:${ns}`); if (kind) chips.push(`kind:${kind}`); if (focus) chips.push(`focus:${focus}`); if (groupBy) chips.push(`group:${groupBy}`); if (!chips.length) { const empty = document.createElement("span"); empty.className = "chip"; empty.textContent = "No active filters"; box.appendChild(empty); return; } chips.forEach((chipText) => { const el = document.createElement("span"); el.className = "chip"; el.textContent = chipText; box.appendChild(el); }); } function renderFindings(findings) { const summary = document.getElementById("findings-summary"); const list = document.getElementById("findings-list"); list.innerHTML = ""; if (!findings.length) { summary.textContent = "No findings."; return; } const errors = findings.filter((f) => f.severity === "error").length; const warnings = findings.filter((f) => f.severity === "warning").length; summary.textContent = `${errors} errors, ${warnings} warnings`; for (const f of findings.slice(0, 80)) { const li = document.createElement("li"); li.classList.add(`finding-${f.severity}`); const target = f.resourceId ? ` [${f.resourceId}]` : ""; li.textContent = `${f.category.toUpperCase()} [${f.rule}] ${f.severity.toUpperCase()}${target}: ${f.message}`; list.appendChild(li); } } function updateExportLinks(params) { const query = params.toString(); document.getElementById("export-svg").href = `/api/export/svg${query ? `?${query}` : ""}`; document.getElementById("export-png").href = `/api/export/png${query ? `?${query}` : ""}`; } function renderGraph() { const svg = document.getElementById("graph-canvas"); const tooltip = document.getElementById("edge-tooltip"); const noMatch = document.getElementById("no-match"); const emptyState = document.getElementById("graph-empty"); const { nodes = [], edges = [] } = window.KubeViz.graph; const pathNodes = window.KubeViz.path.nodes || new Set(); const pathEdges = window.KubeViz.path.edges || new Set(); svg.innerHTML = ""; tooltip.classList.add("hidden"); noMatch.classList.add("hidden"); emptyState.classList.add("hidden"); attachZoomHandlers(svg); if (!nodes.length) { if (window.KubeViz.lastParsedResources > 0) { noMatch.classList.remove("hidden"); } else { emptyState.classList.remove("hidden"); } return; } const viewport = document.createElementNS("http://www.w3.org/2000/svg", "g"); viewport.setAttribute("id", "graph-viewport"); svg.appendChild(viewport); const positions = assignPositions(nodes); window.KubeViz.positions = positions; const nodeMap = new Map(nodes.map((n) => [n.id, n])); for (const edge of edges) { const source = positions[edge.source]; const target = positions[edge.target]; if (!source || !target) continue; const key = edgeKey(edge.source, edge.target, edge.relationType); const reverseKey = edgeKey(edge.target, edge.source, edge.relationType); const isPathEdge = pathEdges.has(key) || pathEdges.has(reverseKey); const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); line.setAttribute("x1", source.x); line.setAttribute("y1", source.y); line.setAttribute("x2", target.x); line.setAttribute("y2", target.y); line.setAttribute("stroke", isPathEdge ? getCssVar("--accent", "#0f766e") : getCssVar("--line-strong", "#94a3b8")); line.setAttribute("stroke-width", isPathEdge ? "3" : "1.5"); line.setAttribute("data-relation", edge.relationType); line.setAttribute("data-source", edge.source); line.setAttribute("data-target", edge.target); line.setAttribute("data-edge-key", key); line.addEventListener("mousemove", (event) => { const src = nodeMap.get(edge.source); const tgt = nodeMap.get(edge.target); const srcLabel = src ? `${src.kind}/${src.name}` : edge.source; const tgtLabel = tgt ? `${tgt.kind}/${tgt.name}` : edge.target; const extra = edge.label ? `\n${edge.label}` : ""; showTooltip(event.clientX, event.clientY, `${edge.relationType}: ${srcLabel} -> ${tgtLabel}${extra}`); }); line.addEventListener("mouseleave", hideTooltip); viewport.appendChild(line); if (edge.label) { const edgeLabel = document.createElementNS("http://www.w3.org/2000/svg", "text"); edgeLabel.setAttribute("class", "edge-label"); edgeLabel.setAttribute("text-anchor", "middle"); edgeLabel.setAttribute("data-edge-label", `${edge.source}|${edge.target}|${edge.relationType}`); edgeLabel.textContent = edge.label; viewport.appendChild(edgeLabel); } } for (const node of nodes) { const pos = positions[node.id]; const group = document.createElementNS("http://www.w3.org/2000/svg", "g"); group.setAttribute("class", "node"); group.setAttribute("data-node-id", node.id); const isPathNode = pathNodes.has(node.id); const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); circle.setAttribute("cx", pos.x); circle.setAttribute("cy", pos.y); circle.setAttribute("r", node.isGroup ? "22" : "18"); circle.setAttribute("fill", nodeColor(node)); circle.setAttribute("opacity", "0.92"); circle.style.cursor = "grab"; circle.addEventListener("click", () => onSelectNode(node.id)); circle.addEventListener("pointerdown", (event) => startNodeDrag(event, node.id)); if (isPathNode) { circle.setAttribute("stroke", getCssVar("--accent", "#0f766e")); circle.setAttribute("stroke-width", "3"); } if (node.healthHint === "warning") { circle.setAttribute("stroke", "#f59e0b"); circle.setAttribute("stroke-width", "2"); circle.setAttribute("data-warning", "true"); } const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); text.setAttribute("x", pos.x); text.setAttribute("y", pos.y + 32); text.setAttribute("text-anchor", "middle"); text.setAttribute("font-size", "11"); text.setAttribute("fill", getCssVar("--ink", "#0f172a")); text.textContent = node.isGroup ? `${node.groupKey} (${node.memberCount})` : `${node.kind}/${node.name}`; group.appendChild(circle); group.appendChild(text); viewport.appendChild(group); } attachDragAndPanHandlers(svg); updateViewportTransform(); refreshEdgeAndNodeGeometry(); highlightSelected(); renderMinimap(); } function nodeColor(node) { if (node.isGroup) return "#7c3aed"; if (node.isSensitive) return "#dc2626"; if (["Service", "Ingress", "NetworkPolicy"].includes(node.kind)) return "#2563eb"; if (["ConfigMap", "PersistentVolumeClaim", "HorizontalPodAutoscaler"].includes(node.kind)) return "#14b8a6"; if (["Deployment", "StatefulSet", "DaemonSet"].includes(node.kind)) return "#0f766e"; return "#334155"; } function assignPositions(nodes) { const existing = window.KubeViz.positions || {}; const needsLayout = nodes.some((n) => !existing[n.id]); if (!needsLayout) return existing; const mode = document.getElementById("layout-mode").value; const laidOut = layoutNodes(nodes, mode, 1200, 720); for (const node of nodes) { if (!existing[node.id]) existing[node.id] = laidOut[node.id]; } return existing; } function layoutNodes(nodes, mode, width, height) { switch (mode) { case "hierarchical": return hierarchicalLayout(nodes, width, height); case "grid": return gridLayout(nodes, width, height); case "radial": return radialLayout(nodes, width, height); case "circular": default: return circularLayout(nodes, 600, 360, Math.min(260, 80 + nodes.length * 2)); } } function circularLayout(nodes, centerX, centerY, radius) { const positions = {}; const sorted = [...nodes].sort((a, b) => a.id.localeCompare(b.id)); sorted.forEach((node, index) => { const angle = (2 * Math.PI * index) / sorted.length; positions[node.id] = { x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle) }; }); return positions; } function hierarchicalLayout(nodes, width, height) { const positions = {}; const byKind = new Map(); for (const n of nodes) { if (!byKind.has(n.kind)) byKind.set(n.kind, []); byKind.get(n.kind).push(n); } const kinds = [...byKind.keys()].sort(); const levelHeight = Math.max(80, (height - 140) / Math.max(1, kinds.length)); kinds.forEach((kind, level) => { const levelNodes = byKind.get(kind).sort((a, b) => a.id.localeCompare(b.id)); const span = width - 120; const step = levelNodes.length > 1 ? span / (levelNodes.length - 1) : 0; levelNodes.forEach((n, i) => { positions[n.id] = { x: 60 + (levelNodes.length > 1 ? step * i : span / 2), y: 70 + level * levelHeight }; }); }); return positions; } function gridLayout(nodes, width, height) { const positions = {}; const sorted = [...nodes].sort((a, b) => a.id.localeCompare(b.id)); const cols = Math.max(2, Math.ceil(Math.sqrt(sorted.length))); const rows = Math.max(1, Math.ceil(sorted.length / cols)); const xGap = (width - 120) / Math.max(1, cols - 1); const yGap = (height - 120) / Math.max(1, rows - 1); sorted.forEach((n, idx) => { const r = Math.floor(idx / cols); const c = idx % cols; positions[n.id] = { x: 60 + c * xGap, y: 60 + r * yGap }; }); return positions; } function radialLayout(nodes, width, height) { const positions = {}; const sorted = [...nodes].sort((a, b) => a.id.localeCompare(b.id)); const center = { x: width / 2, y: height / 2 }; const rings = [120, 210, 300]; sorted.forEach((n, idx) => { const ring = rings[idx % rings.length]; const angle = (2 * Math.PI * idx) / sorted.length; positions[n.id] = { x: center.x + ring * Math.cos(angle), y: center.y + ring * Math.sin(angle) }; }); return positions; } async function onSelectNode(id) { window.KubeViz.selectedId = id; highlightSelected(); if (id.startsWith("group/")) { document.getElementById("selected-kind-name").textContent = id; document.getElementById("kind").value = ""; setSelectedResource({ kind: "Group", name: id, namespace: "", raw: {}, isSensitive: false }); return; } const res = await fetch(`/api/resources/${id}`); if (!res.ok) { setSelectedResource(null); return; } const payload = await res.json(); setSelectedResource(payload); document.getElementById("focus").value = id; document.getElementById("kind").value = payload.kind || ""; document.getElementById("selected-kind-name").textContent = `${payload.kind || ""} ${payload.name || ""}`.trim(); syncPathInputs(id); } function jumpToSearchMatch() { const needle = document.getElementById("query").value.trim().toLowerCase(); if (!needle) { hideSearchSuggestions(); return; } const nodes = window.KubeViz.graph.nodes || []; const match = nodes.find((n) => { const hay = `${n.id} ${n.kind} ${n.name} ${n.namespace || ""}`.toLowerCase(); return hay.includes(needle); }); if (!match) return; onSelectNode(match.id); centerOnNode(match.id); hideSearchSuggestions(); } function onSearchKeydown(event) { const suggestions = window.KubeViz.search.suggestions || []; if (!suggestions.length) return; if (event.key === "ArrowDown") { event.preventDefault(); window.KubeViz.search.activeIndex = (window.KubeViz.search.activeIndex + 1) % suggestions.length; renderSearchSuggestions(); return; } if (event.key === "ArrowUp") { event.preventDefault(); window.KubeViz.search.activeIndex = window.KubeViz.search.activeIndex <= 0 ? suggestions.length - 1 : window.KubeViz.search.activeIndex - 1; renderSearchSuggestions(); return; } if (event.key === "Enter") { const idx = window.KubeViz.search.activeIndex; if (idx >= 0 && suggestions[idx]) { event.preventDefault(); pickSuggestion(suggestions[idx].id); } return; } if (event.key === "Escape") hideSearchSuggestions(); } function updateSearchSuggestions() { const query = document.getElementById("query").value.trim().toLowerCase(); if (!query) { window.KubeViz.search.suggestions = []; window.KubeViz.search.activeIndex = -1; hideSearchSuggestions(); return; } const nodes = window.KubeViz.graph.nodes || []; window.KubeViz.search.suggestions = nodes .filter((n) => { const hay = `${n.name} ${n.kind} ${n.namespace || ""} ${n.id}`.toLowerCase(); return hay.includes(query); }) .slice(0, 8); window.KubeViz.search.activeIndex = window.KubeViz.search.suggestions.length ? 0 : -1; renderSearchSuggestions(); } function renderSearchSuggestions() { const box = document.getElementById("search-suggest"); const suggestions = window.KubeViz.search.suggestions || []; box.innerHTML = ""; if (!suggestions.length) { hideSearchSuggestions(); return; } suggestions.forEach((item, idx) => { const row = document.createElement("button"); row.type = "button"; row.className = `suggest-item${idx === window.KubeViz.search.activeIndex ? " active" : ""}`; row.setAttribute("role", "option"); row.setAttribute("aria-selected", idx === window.KubeViz.search.activeIndex ? "true" : "false"); row.textContent = `${item.kind}/${item.name} (${item.namespace || "cluster"})`; row.addEventListener("mousedown", (event) => { event.preventDefault(); pickSuggestion(item.id); }); box.appendChild(row); }); box.classList.remove("hidden"); } function pickSuggestion(nodeId) { onSelectNode(nodeId); centerOnNode(nodeId); hideSearchSuggestions(); } function hideSearchSuggestions() { const box = document.getElementById("search-suggest"); box.classList.add("hidden"); } function bindPathSuggestInput(input, field) { input.addEventListener("input", () => updatePathSuggestions(field)); input.addEventListener("keydown", (event) => onPathSuggestKeydown(event, field)); input.addEventListener("blur", () => { window.setTimeout(() => hidePathSuggestions(field), 120); }); } function updatePathSuggestions(field) { const input = document.getElementById(`path-${field}`); if (!input) return; const query = input.value.trim().toLowerCase(); const state = window.KubeViz.pathSuggest[field]; if (!query) { state.items = []; state.index = -1; hidePathSuggestions(field); return; } const nodes = window.KubeViz.graph.nodes || []; state.items = nodes .filter((n) => { const hay = `${n.id} ${n.kind} ${n.name} ${n.namespace || ""}`.toLowerCase(); return hay.includes(query); }) .slice(0, 8); state.index = state.items.length ? 0 : -1; renderPathSuggestions(field); } function renderPathSuggestions(field) { const state = window.KubeViz.pathSuggest[field]; const box = document.getElementById(`path-${field}-suggest`); if (!box) return; box.innerHTML = ""; if (!state.items.length) { hidePathSuggestions(field); return; } state.items.forEach((item, idx) => { const row = document.createElement("button"); row.type = "button"; row.className = `suggest-item${idx === state.index ? " active" : ""}`; row.setAttribute("role", "option"); row.setAttribute("aria-selected", idx === state.index ? "true" : "false"); row.textContent = `${item.id} (${item.kind})`; row.addEventListener("mousedown", (event) => { event.preventDefault(); pickPathSuggestion(field, item.id); }); box.appendChild(row); }); box.classList.remove("hidden"); } function onPathSuggestKeydown(event, field) { const state = window.KubeViz.pathSuggest[field]; if (!state.items.length) return; if (event.key === "ArrowDown") { event.preventDefault(); state.index = (state.index + 1) % state.items.length; renderPathSuggestions(field); return; } if (event.key === "ArrowUp") { event.preventDefault(); state.index = state.index <= 0 ? state.items.length - 1 : state.index - 1; renderPathSuggestions(field); return; } if (event.key === "Enter") { if (state.index >= 0 && state.items[state.index]) { event.preventDefault(); pickPathSuggestion(field, state.items[state.index].id); } return; } if (event.key === "Escape") hidePathSuggestions(field); } function pickPathSuggestion(field, id) { const input = document.getElementById(`path-${field}`); if (!input) return; input.value = id; hidePathSuggestions(field); } function hidePathSuggestions(field) { const box = document.getElementById(`path-${field}-suggest`); if (!box) return; box.classList.add("hidden"); } function initMinimap(minimap) { minimap.addEventListener("click", (event) => { const rect = minimap.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; const x = ((event.clientX - rect.left) / rect.width) * 1200; const y = ((event.clientY - rect.top) / rect.height) * 720; const scale = window.KubeViz.view.scale || 1; window.KubeViz.view.panX = (600 - x) * scale; window.KubeViz.view.panY = (360 - y) * scale; updateViewportTransform(); }); } function renderMinimap() { const minimap = document.getElementById("minimap-canvas"); if (!minimap) return; minimap.innerHTML = ""; const { nodes = [], edges = [] } = window.KubeViz.graph; const positions = window.KubeViz.positions || {}; if (!nodes.length) return; for (const edge of edges) { const source = positions[edge.source]; const target = positions[edge.target]; if (!source || !target) continue; const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); line.setAttribute("x1", source.x); line.setAttribute("y1", source.y); line.setAttribute("x2", target.x); line.setAttribute("y2", target.y); line.setAttribute("stroke", getCssVar("--line-strong", "#94a3b8")); line.setAttribute("stroke-width", "3"); line.setAttribute("opacity", "0.6"); minimap.appendChild(line); } for (const node of nodes) { const pos = positions[node.id]; if (!pos) continue; const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle"); dot.setAttribute("cx", pos.x); dot.setAttribute("cy", pos.y); dot.setAttribute("r", "7"); dot.setAttribute("fill", nodeColor(node)); dot.setAttribute("opacity", "0.85"); minimap.appendChild(dot); } const viewportRect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); viewportRect.setAttribute("id", "minimap-viewport"); viewportRect.setAttribute("fill", "none"); viewportRect.setAttribute("stroke", getCssVar("--accent", "#0f766e")); viewportRect.setAttribute("stroke-width", "6"); minimap.appendChild(viewportRect); updateMinimapViewport(); } function updateMinimapViewport() { const rect = document.getElementById("minimap-viewport"); if (!rect) return; const { scale, panX, panY } = window.KubeViz.view; const safeScale = scale || 1; const viewWidth = 1200 / safeScale; const viewHeight = 720 / safeScale; const centerX = 600 - panX / safeScale; const centerY = 360 - panY / safeScale; const x = centerX - viewWidth / 2; const y = centerY - viewHeight / 2; rect.setAttribute("x", x.toFixed(2)); rect.setAttribute("y", y.toFixed(2)); rect.setAttribute("width", viewWidth.toFixed(2)); rect.setAttribute("height", viewHeight.toFixed(2)); } function getCurrentView() { const relations = []; document.querySelectorAll('input[name="relations"]:checked').forEach((el) => relations.push(el.value)); const checkRules = []; document.querySelectorAll('input[name="checkRules"]:checked').forEach((el) => checkRules.push(el.value)); return { query: document.getElementById("query").value.trim(), namespace: document.getElementById("namespace").value.trim(), kind: document.getElementById("kind").value.trim(), focus: document.getElementById("focus").value.trim(), groupBy: document.getElementById("groupBy").value, layoutMode: document.getElementById("layout-mode").value, relations, checkRules, }; } function saveCurrentView() { const name = document.getElementById("view-name").value.trim(); if (!name) { setStatus("Please provide a view name before saving.", "warn"); return; } const views = readSavedViews(); const view = { name, config: getCurrentView() }; const idx = views.findIndex((v) => v.name === name); if (idx >= 0) views[idx] = view; else views.push(view); localStorage.setItem(SAVED_VIEWS_KEY, JSON.stringify(views)); renderSavedViews(name); setStatus(`Saved view '${name}'.`, "ok"); } function loadSelectedView() { const selected = document.getElementById("saved-views").value; if (!selected) return; const views = readSavedViews(); const entry = views.find((v) => v.name === selected); if (!entry) return; applySavedView(entry.config); setStatus(`Loaded view '${entry.name}'.`, "info"); } function deleteSelectedView() { const selected = document.getElementById("saved-views").value; if (!selected) return; const next = readSavedViews().filter((v) => v.name !== selected); localStorage.setItem(SAVED_VIEWS_KEY, JSON.stringify(next)); renderSavedViews(); setStatus(`Deleted view '${selected}'.`, "info"); } function exportSavedViews() { const views = readSavedViews(); if (!views.length) { setStatus("No saved views to export.", "warn"); return; } const blob = new Blob([JSON.stringify(views, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "kubeviz-saved-views.json"; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); setStatus(`Exported ${views.length} saved view(s).`, "info"); } async function importSavedViewsFromFile(event) { const input = event.target; const file = input.files && input.files[0]; if (!file) return; try { const text = await file.text(); const parsed = JSON.parse(text); const imported = normalizeSavedViews(parsed); if (!imported.length) { setStatus("Import file has no valid saved views.", "warn"); input.value = ""; return; } const existing = readSavedViews(); const merged = mergeSavedViews(existing, imported); localStorage.setItem(SAVED_VIEWS_KEY, JSON.stringify(merged)); renderSavedViews(); setStatus(`Imported ${imported.length} view(s).`, "ok"); } catch (_err) { setStatus("Failed to import views JSON.", "error"); } finally { input.value = ""; } } function normalizeSavedViews(value) { if (!Array.isArray(value)) return []; return value .filter((v) => v && typeof v.name === "string" && typeof v.config === "object") .map((v) => ({ name: v.name.trim(), config: v.config })) .filter((v) => !!v.name); } function mergeSavedViews(existing, incoming) { const map = new Map(); existing.forEach((v) => map.set(v.name, v)); incoming.forEach((v) => map.set(v.name, v)); return [...map.values()]; } function applySavedView(config) { document.getElementById("query").value = config.query || ""; document.getElementById("namespace").value = config.namespace || ""; document.getElementById("kind").value = config.kind || ""; document.getElementById("focus").value = config.focus || ""; document.getElementById("groupBy").value = config.groupBy || ""; document.getElementById("layout-mode").value = config.layoutMode || "circular"; document.querySelectorAll('input[name="relations"]').forEach((el) => { el.checked = (config.relations || []).includes(el.value); }); document.querySelectorAll('input[name="checkRules"]').forEach((el) => { el.checked = (config.checkRules || []).includes(el.value); }); window.KubeViz.positions = {}; fetchAndRenderGraph({ autoNamespace: false, resetLayout: true }); } function renderSavedViews(selectedName = "") { const select = document.getElementById("saved-views"); const views = readSavedViews(); select.innerHTML = ""; if (!views.length) { const option = document.createElement("option"); option.value = ""; option.textContent = "No saved views"; select.appendChild(option); return; } views.forEach((view) => { const option = document.createElement("option"); option.value = view.name; option.textContent = view.name; if (selectedName && view.name === selectedName) option.selected = true; select.appendChild(option); }); } function readSavedViews() { try { const raw = localStorage.getItem(SAVED_VIEWS_KEY); if (!raw) return []; return normalizeSavedViews(JSON.parse(raw)); } catch (_err) { return []; } } function runPathFinder() { const source = document.getElementById("path-source").value.trim(); const target = document.getElementById("path-target").value.trim(); const bidirectional = document.getElementById("path-bidirectional").checked; if (!source || !target) { setPathResult("Please provide source and target resource IDs."); return; } const path = findPath(source, target, bidirectional); if (!path) { clearPathHighlight(false); setPathResult(`No path found from ${source} to ${target}.`); return; } const nodeSet = new Set(path.nodes); const edgeSet = new Set(path.edges.map((e) => edgeKey(e.source, e.target, e.relationType))); window.KubeViz.path.nodes = nodeSet; window.KubeViz.path.edges = edgeSet; window.KubeViz.path.ids = path.nodes; renderGraph(); setPathResult(`Path (${path.nodes.length} nodes)`, path.nodes); } function clearPathHighlight(withMessage = true) { window.KubeViz.path.nodes = new Set(); window.KubeViz.path.edges = new Set(); window.KubeViz.path.ids = []; renderGraph(); if (withMessage) setPathResult("Path highlight cleared."); } function setPathResult(message, steps = []) { const box = document.getElementById("path-result"); box.innerHTML = ""; const head = document.createElement("div"); head.textContent = message; box.appendChild(head); if (!steps.length) return; const flow = document.createElement("div"); flow.className = "path-steps"; steps.forEach((id, idx) => { const btn = document.createElement("button"); btn.type = "button"; btn.className = "path-step"; btn.textContent = id; btn.addEventListener("click", async () => { await onSelectNode(id); centerOnNode(id); }); flow.appendChild(btn); if (idx < steps.length - 1) { const arrow = document.createElement("span"); arrow.className = "path-arrow"; arrow.textContent = "->"; flow.appendChild(arrow); } }); box.appendChild(flow); } function findPath(source, target, bidirectional) { const { nodes = [], edges = [] } = window.KubeViz.graph; const nodeIds = new Set(nodes.map((n) => n.id)); if (!nodeIds.has(source) || !nodeIds.has(target)) return null; const adjacency = new Map(); nodes.forEach((n) => adjacency.set(n.id, [])); edges.forEach((e) => { adjacency.get(e.source)?.push({ next: e.target, edge: e }); if (bidirectional) { adjacency.get(e.target)?.push({ next: e.source, edge: { ...e, source: e.target, target: e.source } }); } }); const queue = [source]; const visited = new Set([source]); const prev = new Map(); while (queue.length) { const cur = queue.shift(); if (cur === target) break; const nexts = adjacency.get(cur) || []; for (const item of nexts) { if (visited.has(item.next)) continue; visited.add(item.next); prev.set(item.next, { node: cur, edge: item.edge }); queue.push(item.next); } } if (!visited.has(target)) return null; const outNodes = []; const outEdges = []; let cur = target; while (cur) { outNodes.push(cur); const entry = prev.get(cur); if (!entry) break; outEdges.push(entry.edge); cur = entry.node; } outNodes.reverse(); outEdges.reverse(); return { nodes: outNodes, edges: outEdges }; } function syncPathInputs(nodeID) { const source = document.getElementById("path-source"); const target = document.getElementById("path-target"); if (!source.value.trim()) { source.value = nodeID; return; } if (!target.value.trim()) { target.value = nodeID; return; } source.value = nodeID; } function edgeKey(source, target, relationType) { return `${source}|${target}|${relationType}`; } function centerOnNode(nodeId) { const pos = window.KubeViz.positions[nodeId]; if (!pos) return; const scale = window.KubeViz.view.scale || 1; window.KubeViz.view.panX = (600 - pos.x) * scale; window.KubeViz.view.panY = (360 - pos.y) * scale; updateViewportTransform(); } function highlightSelected() { const selectedID = window.KubeViz.selectedId; const svg = document.getElementById("graph-canvas"); svg.querySelectorAll("circle").forEach((circle) => { if (circle.getAttribute("data-warning") === "true") return; circle.setAttribute("stroke", "none"); circle.setAttribute("stroke-width", "0"); }); if (!selectedID) return; const group = svg.querySelector(`g[data-node-id="${cssEscape(selectedID)}"]`); const circle = group ? group.querySelector("circle") : null; if (circle) { circle.setAttribute("stroke", "#f59e0b"); circle.setAttribute("stroke-width", "4"); } } function updateStats(stats) { document.getElementById("total-nodes").textContent = String(stats.totalNodes || 0); document.getElementById("total-edges").textContent = String(stats.totalEdges || 0); } function setSelectedResource(resource) { const emptyEl = document.getElementById("details-empty"); const contentEl = document.getElementById("details-content"); const kindEl = document.getElementById("details-kind"); const nameEl = document.getElementById("details-name"); const nsEl = document.getElementById("details-namespace"); const sensitiveEl = document.getElementById("details-sensitive"); const rawEl = document.getElementById("details-raw"); if (!resource) { emptyEl.classList.remove("hidden"); contentEl.classList.add("hidden"); return; } kindEl.textContent = resource.kind || ""; nameEl.textContent = resource.name || ""; nsEl.textContent = resource.namespace || "(cluster-scoped)"; rawEl.textContent = JSON.stringify(resource.raw || {}, null, 2); if (resource.isSensitive) sensitiveEl.classList.remove("hidden"); else sensitiveEl.classList.add("hidden"); emptyEl.classList.add("hidden"); contentEl.classList.remove("hidden"); } function attachZoomHandlers(svg) { svg.onwheel = (event) => { event.preventDefault(); const factor = event.deltaY < 0 ? 1.1 : 0.9; setZoom(window.KubeViz.view.scale * factor); }; } function setZoom(nextScale) { const view = window.KubeViz.view; view.scale = Math.max(view.minScale, Math.min(view.maxScale, nextScale)); updateViewportTransform(); } function resetZoom() { window.KubeViz.view.scale = 1; window.KubeViz.view.panX = 0; window.KubeViz.view.panY = 0; updateViewportTransform(); } function updateViewportTransform() { const viewport = document.getElementById("graph-viewport"); if (!viewport) return; const { scale, panX, panY } = window.KubeViz.view; viewport.setAttribute("transform", `translate(${600 + panX} ${360 + panY}) scale(${scale}) translate(-600 -360)`); updateMinimapViewport(); } function attachDragAndPanHandlers(svg) { svg.onpointerdown = (event) => { if (event.target.closest("g.node")) return; window.KubeViz.dragging.panning = true; window.KubeViz.dragging.panStartX = event.clientX; window.KubeViz.dragging.panStartY = event.clientY; }; svg.onpointermove = (event) => { const draggingID = window.KubeViz.dragging.nodeId; if (draggingID) { const pt = svgPointFromEvent(svg, event); window.KubeViz.positions[draggingID] = { x: pt.x, y: pt.y }; refreshEdgeAndNodeGeometry(); return; } if (window.KubeViz.dragging.panning) { const dx = event.clientX - window.KubeViz.dragging.panStartX; const dy = event.clientY - window.KubeViz.dragging.panStartY; window.KubeViz.dragging.panStartX = event.clientX; window.KubeViz.dragging.panStartY = event.clientY; window.KubeViz.view.panX += dx; window.KubeViz.view.panY += dy; updateViewportTransform(); } }; svg.onpointerup = stopPointerInteractions; svg.onpointerleave = stopPointerInteractions; } function startNodeDrag(event, nodeId) { event.preventDefault(); event.stopPropagation(); window.KubeViz.dragging.nodeId = nodeId; const circle = event.currentTarget; if (circle) circle.style.cursor = "grabbing"; } function stopPointerInteractions() { window.KubeViz.dragging.nodeId = null; window.KubeViz.dragging.panning = false; document.querySelectorAll("#graph-canvas circle").forEach((c) => { c.style.cursor = "grab"; }); } function refreshEdgeAndNodeGeometry() { const svg = document.getElementById("graph-canvas"); const positions = window.KubeViz.positions || {}; const { edges = [] } = window.KubeViz.graph; svg.querySelectorAll("g.node").forEach((group) => { const nodeId = group.getAttribute("data-node-id"); const pos = positions[nodeId]; if (!pos) return; const circle = group.querySelector("circle"); const text = group.querySelector("text"); if (circle) { circle.setAttribute("cx", pos.x); circle.setAttribute("cy", pos.y); } if (text) { text.setAttribute("x", pos.x); text.setAttribute("y", pos.y + 32); } }); svg.querySelectorAll("line[data-source][data-target]").forEach((line) => { const source = positions[line.getAttribute("data-source")]; const target = positions[line.getAttribute("data-target")]; if (!source || !target) return; line.setAttribute("x1", source.x); line.setAttribute("y1", source.y); line.setAttribute("x2", target.x); line.setAttribute("y2", target.y); }); for (const edge of edges) { if (!edge.label) continue; const source = positions[edge.source]; const target = positions[edge.target]; if (!source || !target) continue; const selector = `text[data-edge-label="${cssEscape(`${edge.source}|${edge.target}|${edge.relationType}`)}"]`; const label = svg.querySelector(selector); if (!label) continue; label.setAttribute("x", ((source.x + target.x) / 2).toFixed(1)); label.setAttribute("y", (((source.y + target.y) / 2) - 6).toFixed(1)); } } function svgPointFromEvent(svg, event) { const pt = svg.createSVGPoint(); pt.x = event.clientX; pt.y = event.clientY; const ctm = svg.getScreenCTM(); if (!ctm) return { x: 0, y: 0 }; const transformed = pt.matrixTransform(ctm.inverse()); const { scale, panX, panY } = window.KubeViz.view; return { x: (transformed.x - (600 + panX)) / scale + 600, y: (transformed.y - (360 + panY)) / scale + 360, }; } function showTooltip(clientX, clientY, text) { const tooltip = document.getElementById("edge-tooltip"); tooltip.textContent = text; tooltip.classList.remove("hidden"); const panel = document.querySelector(".graph-panel"); const rect = panel.getBoundingClientRect(); tooltip.style.left = `${clientX - rect.left + 12}px`; tooltip.style.top = `${clientY - rect.top + 12}px`; } function hideTooltip() { document.getElementById("edge-tooltip").classList.add("hidden"); } function resetFiltersToDefault() { document.getElementById("query").value = ""; document.getElementById("namespace").value = ""; document.getElementById("kind").value = ""; document.getElementById("focus").value = ""; document.getElementById("groupBy").value = ""; document.querySelectorAll('input[name="relations"]').forEach((el) => { el.checked = true; }); window.KubeViz.positions = {}; window.KubeViz.view.panX = 0; window.KubeViz.view.panY = 0; } async function runDiff() { const output = document.getElementById("diff-output"); const base = document.getElementById("diff-base").value; const target = document.getElementById("diff-target").value; if (!base.trim() || !target.trim()) { output.textContent = "Please provide both base and target manifests."; return; } const res = await fetch("/api/diff", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ baseManifest: base, targetManifest: target }), }); if (!res.ok) { output.textContent = `Diff failed: ${await res.text()}`; return; } const diff = await res.json(); output.innerHTML = ""; const summary = document.createElement("div"); summary.textContent = `Added: ${diff.added.length}, Removed: ${diff.removed.length}, Changed: ${diff.changed.length}`; output.appendChild(summary); appendDiffList(output, "Added", diff.added); appendDiffList(output, "Removed", diff.removed); appendDiffList(output, "Changed", diff.changed); } async function importFromGit(mode) { const repoURL = document.getElementById("git-repo-url").value.trim(); const ref = document.getElementById("git-ref").value.trim(); const path = document.getElementById("git-path").value.trim(); const valuesYAML = document.getElementById("helm-values").value; if (!repoURL) { setStatus("Git repo URL is required.", "warn"); return; } const payload = { repoURL, ref, path, sourceType: mode, valuesYAML, }; setStatus(mode === "helm" ? "Rendering Helm chart from Git..." : "Importing manifests from Git...", "info"); const res = await fetch("/api/git/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!res.ok) { setStatus(`Git import failed: ${await res.text()}`, "error"); return; } const data = await res.json(); const parseIssues = data.summary?.issues || []; const issues = parseIssues.length; const resources = data.summary?.resources || 0; setStatus(`${mode === "helm" ? "Helm rendered" : "Git imported"}: ${resources} resources (${formatIssueCount(issues)}).`, issues > 0 ? "warn" : "ok"); renderParseIssues(parseIssues); await fetchAndRenderGraph({ autoNamespace: true, resetLayout: true }); } function appendDiffList(root, title, items) { const header = document.createElement("strong"); header.textContent = title; root.appendChild(header); const ul = document.createElement("ul"); ul.style.marginTop = "0.25rem"; ul.style.marginBottom = "0.5rem"; for (const item of items.slice(0, 20)) { const li = document.createElement("li"); li.textContent = `${item.id} (${item.kind})`; ul.appendChild(li); } if (!items.length) { const li = document.createElement("li"); li.textContent = "none"; ul.appendChild(li); } root.appendChild(ul); } function cssEscape(value) { if (window.CSS && typeof window.CSS.escape === "function") { return window.CSS.escape(value); } return String(value).replace(/[^a-zA-Z0-9_-]/g, "\\$&"); } function getCssVar(name, fallback = "") { const value = getComputedStyle(document.documentElement).getPropertyValue(name); const normalized = value ? value.trim() : ""; return normalized || fallback; } function isTypingContext(target) { if (!target || !(target instanceof HTMLElement)) return false; const tag = target.tagName; return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable; } function setStatus(message, level = "info") { const status = document.getElementById("parse-status"); status.textContent = message; status.classList.remove("status-error", "status-ok", "status-warn", "status-info"); status.classList.add(`status-${level}`); } function formatIssueCount(count) { return `${count} ${count === 1 ? "issue" : "issues"}`; } function renderParseIssues(issues) { const wrapper = document.getElementById("parse-issues"); const list = document.getElementById("parse-issues-list"); if (!wrapper || !list) return; list.innerHTML = ""; if (!issues || issues.length === 0) { wrapper.classList.add("hidden"); return; } for (const issue of issues.slice(0, 30)) { const li = document.createElement("li"); const docInfo = Number.isFinite(issue.document) && issue.document > 0 ? ` (doc ${issue.document})` : ""; li.textContent = `${issue.message}${docInfo}`; list.appendChild(li); } wrapper.classList.remove("hidden"); }