Files
Clemens Hering 58a10625c3
All checks were successful
Deploy KubeViz / deploy (push) Successful in 11s
make local
2026-03-01 11:37:20 +01:00

1729 lines
56 KiB
JavaScript

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("submit", async (event) => {
event.preventDefault();
const formData = new FormData(parseForm);
const res = await fetch("/api/manifests/parse", {
method: "POST",
body: formData,
});
if (!res.ok) {
const detail = (await res.text()).trim();
const suffix = detail ? ` ${detail}` : " Check YAML syntax and try again.";
setStatus(`Parse failed (${res.status}).${suffix}`, "error");
renderParseIssues([]);
return;
}
const payload = await res.json();
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");
}