1724 lines
56 KiB
JavaScript
1724 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("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");
|
|
}
|