Upload files to "redflare/web"
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
"""Static assets for REDflare's local visual investigation console."""
|
||||||
|
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const NS = "http://www.w3.org/2000/svg";
|
||||||
|
const TYPE_STYLE = {
|
||||||
|
run: { color: "#ff4d5f", size: 26, icon: "RF", order: 0 },
|
||||||
|
target: { color: "#4ad9d9", size: 22, icon: "◎", order: 1 },
|
||||||
|
endpoint: { color: "#58a6ff", size: 13, icon: "↗", order: 2 },
|
||||||
|
module: { color: "#f59e42", size: 12, icon: "m", order: 2 },
|
||||||
|
parameter: { color: "#ac7cff", size: 8, icon: "p", order: 3 },
|
||||||
|
document: { color: "#4fd18b", size: 12, icon: "≡", order: 3 },
|
||||||
|
finding: { color: "#e8c547", size: 12, icon: "!", order: 4 },
|
||||||
|
exposure: { color: "#ff4055", size: 15, icon: "!", order: 4 },
|
||||||
|
cve: { color: "#ff874d", size: 11, icon: "C", order: 5 },
|
||||||
|
standard: { color: "#8491a3", size: 9, icon: "§", order: 5 },
|
||||||
|
};
|
||||||
|
const SEVERITY_COLOR = { critical: "#ff4055", high: "#ff874d", medium: "#e8c547", low: "#58a6ff", info: "#8491a3" };
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
graph: null,
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
nodeMap: new Map(),
|
||||||
|
hiddenTypes: new Set(),
|
||||||
|
layout: "force",
|
||||||
|
selected: null,
|
||||||
|
connected: new Set(),
|
||||||
|
search: "",
|
||||||
|
matches: new Set(),
|
||||||
|
transform: { x: 0, y: 0, k: 1 },
|
||||||
|
animation: null,
|
||||||
|
dragged: null,
|
||||||
|
dragStart: null,
|
||||||
|
dragMoved: false,
|
||||||
|
suppressClick: false,
|
||||||
|
panning: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const svg = document.getElementById("graph");
|
||||||
|
const viewport = document.getElementById("viewport");
|
||||||
|
const edgeLayer = document.getElementById("edges");
|
||||||
|
const edgeLabelLayer = document.getElementById("edge-labels");
|
||||||
|
const nodeLayer = document.getElementById("nodes");
|
||||||
|
const tooltip = document.getElementById("tooltip");
|
||||||
|
|
||||||
|
function el(tag, attrs = {}, text = null) {
|
||||||
|
const node = document.createElementNS(NS, tag);
|
||||||
|
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, String(value));
|
||||||
|
if (text !== null) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlEl(tag, className = "", text = null) {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text !== null) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
const response = await fetch("/api/graph", { cache: "no-store" });
|
||||||
|
if (!response.ok) throw new Error(`Graph request failed: ${response.status}`);
|
||||||
|
state.graph = await response.json();
|
||||||
|
state.nodes = state.graph.nodes.map((node, index) => ({ ...node, x: 0, y: 0, vx: 0, vy: 0, index }));
|
||||||
|
state.edges = state.graph.edges.map(edge => ({ ...edge }));
|
||||||
|
state.nodeMap = new Map(state.nodes.map(node => [node.id, node]));
|
||||||
|
document.getElementById("run-id").textContent = state.graph.metadata.run_id;
|
||||||
|
buildStats();
|
||||||
|
buildFilters();
|
||||||
|
bindControls();
|
||||||
|
seedPositions();
|
||||||
|
applyLayout("force");
|
||||||
|
requestAnimationFrame(() => fitView(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStats() {
|
||||||
|
const meta = state.graph.metadata;
|
||||||
|
const stats = document.getElementById("stats");
|
||||||
|
stats.replaceChildren();
|
||||||
|
const values = [
|
||||||
|
[meta.nodes, "nodes"], [meta.edges, "edges"],
|
||||||
|
[meta.type_counts.endpoint || 0, "endpoints"],
|
||||||
|
[(meta.type_counts.finding || 0) + (meta.type_counts.exposure || 0), "findings"],
|
||||||
|
];
|
||||||
|
for (const [value, label] of values) {
|
||||||
|
const card = htmlEl("div", "stat");
|
||||||
|
card.append(htmlEl("strong", "", String(value)), htmlEl("span", "", label));
|
||||||
|
stats.append(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFilters() {
|
||||||
|
const filters = document.getElementById("filters");
|
||||||
|
filters.replaceChildren();
|
||||||
|
const counts = state.graph.metadata.type_counts;
|
||||||
|
const types = Object.keys(counts).sort((a, b) => (TYPE_STYLE[a]?.order ?? 99) - (TYPE_STYLE[b]?.order ?? 99));
|
||||||
|
for (const type of types) {
|
||||||
|
const row = htmlEl("label", "filter-row");
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "checkbox";
|
||||||
|
input.checked = true;
|
||||||
|
input.dataset.type = type;
|
||||||
|
input.addEventListener("change", () => {
|
||||||
|
input.checked ? state.hiddenTypes.delete(type) : state.hiddenTypes.add(type);
|
||||||
|
applyLayout(state.layout, true);
|
||||||
|
});
|
||||||
|
const swatch = htmlEl("span", "swatch");
|
||||||
|
swatch.style.color = TYPE_STYLE[type]?.color || "#8491a3";
|
||||||
|
swatch.style.background = TYPE_STYLE[type]?.color || "#8491a3";
|
||||||
|
row.append(input, swatch, htmlEl("span", "", labelFor(type)), htmlEl("span", "filter-count", String(counts[type])));
|
||||||
|
filters.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindControls() {
|
||||||
|
document.querySelectorAll(".layout-button").forEach(button => button.addEventListener("click", () => applyLayout(button.dataset.layout)));
|
||||||
|
document.getElementById("fit-view").addEventListener("click", () => fitView(true));
|
||||||
|
document.getElementById("reset-focus").addEventListener("click", clearFocus);
|
||||||
|
document.getElementById("show-all").addEventListener("click", () => {
|
||||||
|
state.hiddenTypes.clear();
|
||||||
|
document.querySelectorAll("#filters input").forEach(input => { input.checked = true; });
|
||||||
|
applyLayout(state.layout, true);
|
||||||
|
});
|
||||||
|
const search = document.getElementById("graph-search");
|
||||||
|
search.addEventListener("input", () => { state.search = search.value.trim().toLowerCase(); updateSearch(); renderClasses(); });
|
||||||
|
document.addEventListener("keydown", event => {
|
||||||
|
if (event.key === "Escape") clearFocus();
|
||||||
|
if (event.key === "/" && document.activeElement !== search) { event.preventDefault(); search.focus(); }
|
||||||
|
if (event.key.toLowerCase() === "f" && document.activeElement !== search) fitView(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.addEventListener("wheel", event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const point = svgPoint(event.clientX, event.clientY);
|
||||||
|
const old = state.transform.k;
|
||||||
|
const next = clamp(old * (event.deltaY < 0 ? 1.12 : .89), .12, 5);
|
||||||
|
state.transform.x = event.clientX - svg.getBoundingClientRect().left - (point.x * next);
|
||||||
|
state.transform.y = event.clientY - svg.getBoundingClientRect().top - (point.y * next);
|
||||||
|
state.transform.k = next;
|
||||||
|
applyTransform();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
svg.addEventListener("pointerdown", event => {
|
||||||
|
if (event.target.closest?.(".node")) return;
|
||||||
|
state.panning = { x: event.clientX, y: event.clientY, tx: state.transform.x, ty: state.transform.y };
|
||||||
|
svg.classList.add("panning");
|
||||||
|
svg.setPointerCapture(event.pointerId);
|
||||||
|
});
|
||||||
|
svg.addEventListener("pointermove", event => {
|
||||||
|
if (state.dragged) {
|
||||||
|
if (state.dragStart && Math.hypot(event.clientX - state.dragStart.x, event.clientY - state.dragStart.y) > 4) state.dragMoved = true;
|
||||||
|
const point = svgPoint(event.clientX, event.clientY);
|
||||||
|
state.dragged.x = point.x;
|
||||||
|
state.dragged.y = point.y;
|
||||||
|
state.dragged.fx = point.x;
|
||||||
|
state.dragged.fy = point.y;
|
||||||
|
updateGeometry();
|
||||||
|
} else if (state.panning) {
|
||||||
|
state.transform.x = state.panning.tx + event.clientX - state.panning.x;
|
||||||
|
state.transform.y = state.panning.ty + event.clientY - state.panning.y;
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
svg.addEventListener("pointerup", event => {
|
||||||
|
if (state.dragged) { state.dragged.fx = null; state.dragged.fy = null; }
|
||||||
|
state.suppressClick = state.dragMoved;
|
||||||
|
state.dragged = null;
|
||||||
|
state.dragStart = null;
|
||||||
|
state.dragMoved = false;
|
||||||
|
state.panning = null;
|
||||||
|
svg.classList.remove("panning");
|
||||||
|
try { if (svg.hasPointerCapture(event.pointerId)) svg.releasePointerCapture(event.pointerId); } catch (_) {}
|
||||||
|
});
|
||||||
|
svg.addEventListener("click", event => { if (event.target === svg) clearFocus(); });
|
||||||
|
new ResizeObserver(() => { if (state.layout !== "force") applyLayout(state.layout, true); }).observe(svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleGraph() {
|
||||||
|
const nodes = state.nodes.filter(node => !state.hiddenTypes.has(node.type));
|
||||||
|
const ids = new Set(nodes.map(node => node.id));
|
||||||
|
const edges = state.edges.filter(edge => ids.has(edge.source) && ids.has(edge.target));
|
||||||
|
return { nodes, edges, ids };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLayout(layout, preserveTransform = false) {
|
||||||
|
state.layout = layout;
|
||||||
|
document.querySelectorAll(".layout-button").forEach(button => button.classList.toggle("active", button.dataset.layout === layout));
|
||||||
|
if (state.animation) cancelAnimationFrame(state.animation);
|
||||||
|
const graph = visibleGraph();
|
||||||
|
document.getElementById("visible-count").textContent = `${graph.nodes.length} nodes · ${graph.edges.length} edges`;
|
||||||
|
document.getElementById("empty-state").classList.toggle("hidden", graph.nodes.length > 0);
|
||||||
|
document.getElementById("graph-stage").classList.toggle("dense", graph.nodes.length > 75);
|
||||||
|
if (layout === "tree") layoutTree(graph.nodes, graph.edges);
|
||||||
|
else if (layout === "radial") layoutRadial(graph.nodes, graph.edges);
|
||||||
|
else startForce(graph.nodes, graph.edges);
|
||||||
|
renderGraph(graph.nodes, graph.edges);
|
||||||
|
updateSearch();
|
||||||
|
renderClasses();
|
||||||
|
if (!preserveTransform && layout !== "force") requestAnimationFrame(() => fitView(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedPositions() {
|
||||||
|
const { width, height } = dimensions();
|
||||||
|
state.nodes.forEach((node, index) => {
|
||||||
|
const angle = (index / Math.max(1, state.nodes.length)) * Math.PI * 2;
|
||||||
|
const ring = 70 + (TYPE_STYLE[node.type]?.order || 1) * 65;
|
||||||
|
node.x = width / 2 + Math.cos(angle) * ring + (Math.random() - .5) * 50;
|
||||||
|
node.y = height / 2 + Math.sin(angle) * ring + (Math.random() - .5) * 50;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startForce(nodes, edges) {
|
||||||
|
const { width, height } = dimensions();
|
||||||
|
const nodeMap = new Map(nodes.map(node => [node.id, node]));
|
||||||
|
let alpha = 1;
|
||||||
|
const step = () => {
|
||||||
|
alpha *= .965;
|
||||||
|
const n = nodes.length;
|
||||||
|
if (n <= 480) {
|
||||||
|
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) repel(nodes[i], nodes[j], alpha);
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < n; i++) for (let offset = 1; offset <= 36; offset++) repel(nodes[i], nodes[(i + offset * 17) % n], alpha * .7);
|
||||||
|
}
|
||||||
|
for (const edge of edges) {
|
||||||
|
const source = nodeMap.get(edge.source), target = nodeMap.get(edge.target);
|
||||||
|
if (!source || !target) continue;
|
||||||
|
const dx = target.x - source.x, dy = target.y - source.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||||
|
const desired = edge.type === "maps_to" ? 115 : edge.type === "accepts" ? 75 : 145;
|
||||||
|
const force = (distance - desired) * .012 * alpha;
|
||||||
|
source.vx += dx / distance * force; source.vy += dy / distance * force;
|
||||||
|
target.vx -= dx / distance * force; target.vy -= dy / distance * force;
|
||||||
|
}
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.fx != null) { node.x = node.fx; node.y = node.fy; node.vx = node.vy = 0; continue; }
|
||||||
|
node.vx += (width / 2 - node.x) * .0009 * alpha;
|
||||||
|
node.vy += (height / 2 - node.y) * .0009 * alpha;
|
||||||
|
node.vx *= .84; node.vy *= .84;
|
||||||
|
node.x += node.vx; node.y += node.vy;
|
||||||
|
}
|
||||||
|
updateGeometry();
|
||||||
|
if (alpha > .025 && state.layout === "force") state.animation = requestAnimationFrame(step);
|
||||||
|
};
|
||||||
|
state.animation = requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
function repel(a, b, alpha) {
|
||||||
|
if (a === b) return;
|
||||||
|
let dx = b.x - a.x, dy = b.y - a.y;
|
||||||
|
let d2 = dx * dx + dy * dy;
|
||||||
|
if (d2 < 1) { dx = Math.random() - .5; dy = Math.random() - .5; d2 = 1; }
|
||||||
|
const min = radius(a) + radius(b) + 16;
|
||||||
|
const strength = (d2 < min * min ? 6800 : 2300) * alpha / d2;
|
||||||
|
a.vx -= dx * strength; a.vy -= dy * strength;
|
||||||
|
b.vx += dx * strength; b.vy += dy * strength;
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelsFor(nodes, edges) {
|
||||||
|
const ids = new Set(nodes.map(node => node.id));
|
||||||
|
const incoming = new Map(nodes.map(node => [node.id, 0]));
|
||||||
|
const children = new Map(nodes.map(node => [node.id, []]));
|
||||||
|
for (const edge of edges) if (ids.has(edge.source) && ids.has(edge.target)) {
|
||||||
|
children.get(edge.source).push(edge.target);
|
||||||
|
incoming.set(edge.target, (incoming.get(edge.target) || 0) + 1);
|
||||||
|
}
|
||||||
|
const roots = nodes.filter(node => node.type === "run" || incoming.get(node.id) === 0);
|
||||||
|
const levels = new Map();
|
||||||
|
const queue = roots.map(node => [node.id, 0]);
|
||||||
|
while (queue.length) {
|
||||||
|
const [id, level] = queue.shift();
|
||||||
|
if (levels.has(id) && levels.get(id) <= level) continue;
|
||||||
|
levels.set(id, level);
|
||||||
|
for (const child of children.get(id) || []) queue.push([child, level + 1]);
|
||||||
|
}
|
||||||
|
for (const node of nodes) if (!levels.has(node.id)) levels.set(node.id, TYPE_STYLE[node.type]?.order || 5);
|
||||||
|
return levels;
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutTree(nodes, edges) {
|
||||||
|
const { width } = dimensions();
|
||||||
|
const levels = levelsFor(nodes, edges);
|
||||||
|
const groups = new Map();
|
||||||
|
nodes.forEach(node => { const level = levels.get(node.id); if (!groups.has(level)) groups.set(level, []); groups.get(level).push(node); });
|
||||||
|
const maxLevel = Math.max(0, ...groups.keys());
|
||||||
|
for (const [level, group] of groups) {
|
||||||
|
group.sort((a, b) => a.type.localeCompare(b.type) || a.label.localeCompare(b.label));
|
||||||
|
group.forEach((node, index) => {
|
||||||
|
node.x = ((index + 1) / (group.length + 1)) * Math.max(width, group.length * 115);
|
||||||
|
node.y = 90 + level * 145;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
viewport.dataset.height = String(180 + maxLevel * 145);
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutRadial(nodes, edges) {
|
||||||
|
const { width, height } = dimensions();
|
||||||
|
const levels = levelsFor(nodes, edges);
|
||||||
|
const groups = new Map();
|
||||||
|
nodes.forEach(node => { const level = levels.get(node.id); if (!groups.has(level)) groups.set(level, []); groups.get(level).push(node); });
|
||||||
|
const cx = width / 2, cy = height / 2;
|
||||||
|
for (const [level, group] of groups) {
|
||||||
|
const ring = level * 125;
|
||||||
|
group.sort((a, b) => a.type.localeCompare(b.type) || a.label.localeCompare(b.label));
|
||||||
|
group.forEach((node, index) => {
|
||||||
|
const angle = (index / Math.max(1, group.length)) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
node.x = cx + Math.cos(angle) * ring;
|
||||||
|
node.y = cy + Math.sin(angle) * ring;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGraph(nodes, edges) {
|
||||||
|
edgeLayer.replaceChildren(); edgeLabelLayer.replaceChildren(); nodeLayer.replaceChildren();
|
||||||
|
const nodeMap = new Map(nodes.map(node => [node.id, node]));
|
||||||
|
for (const edge of edges) {
|
||||||
|
const source = nodeMap.get(edge.source), target = nodeMap.get(edge.target);
|
||||||
|
if (!source || !target) continue;
|
||||||
|
const line = el("line", { class: "edge", "data-id": edge.id });
|
||||||
|
line.__data__ = edge;
|
||||||
|
edgeLayer.append(line);
|
||||||
|
if (edge.type !== "contains" && edge.type !== "serves") {
|
||||||
|
const label = el("text", { class: "edge-label", "data-id": edge.id }, edge.label || edge.type);
|
||||||
|
label.__data__ = edge;
|
||||||
|
edgeLabelLayer.append(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const node of nodes) {
|
||||||
|
const style = TYPE_STYLE[node.type] || { color: "#8491a3", size: 10, icon: "?" };
|
||||||
|
const color = node.severity ? SEVERITY_COLOR[node.severity] || style.color : style.color;
|
||||||
|
const group = el("g", { class: "node", "data-id": node.id, "data-type": node.type, tabindex: "0", role: "button", "aria-label": `${node.type}: ${node.label}` });
|
||||||
|
group.__data__ = node;
|
||||||
|
group.append(el("circle", { class: "node-ring", r: style.size + 5 }));
|
||||||
|
group.append(el("circle", { class: "node-circle", r: style.size, fill: color }));
|
||||||
|
group.append(el("text", { class: "node-icon", y: 1 }, style.icon));
|
||||||
|
group.append(el("text", { class: "node-label", y: style.size + 15 }, truncate(node.label, node.type === "endpoint" ? 32 : 24)));
|
||||||
|
if (node.type === "exposure") {
|
||||||
|
group.append(el("circle", { class: "node-badge", cx: style.size - 1, cy: -style.size + 1, r: 6 }));
|
||||||
|
group.append(el("text", { class: "node-badge-text", x: style.size - 1, y: -style.size + 1 }, "!"));
|
||||||
|
}
|
||||||
|
group.addEventListener("pointerdown", event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
state.dragged = node;
|
||||||
|
state.dragStart = { x: event.clientX, y: event.clientY };
|
||||||
|
state.dragMoved = false;
|
||||||
|
group.setPointerCapture(event.pointerId);
|
||||||
|
});
|
||||||
|
group.addEventListener("click", event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (state.suppressClick) { state.suppressClick = false; return; }
|
||||||
|
selectNode(node.id);
|
||||||
|
});
|
||||||
|
group.addEventListener("dblclick", event => { event.stopPropagation(); selectNode(node.id); centerOnNode(node); });
|
||||||
|
group.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === " ") selectNode(node.id); });
|
||||||
|
group.addEventListener("mouseenter", event => showTooltip(event, node));
|
||||||
|
group.addEventListener("mousemove", moveTooltip);
|
||||||
|
group.addEventListener("mouseleave", hideTooltip);
|
||||||
|
nodeLayer.append(group);
|
||||||
|
}
|
||||||
|
updateGeometry();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGeometry() {
|
||||||
|
edgeLayer.querySelectorAll(".edge").forEach(line => {
|
||||||
|
const edge = line.__data__, source = state.nodeMap.get(edge.source), target = state.nodeMap.get(edge.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);
|
||||||
|
});
|
||||||
|
edgeLabelLayer.querySelectorAll(".edge-label").forEach(label => {
|
||||||
|
const edge = label.__data__, source = state.nodeMap.get(edge.source), target = state.nodeMap.get(edge.target);
|
||||||
|
if (!source || !target) return;
|
||||||
|
label.setAttribute("x", (source.x + target.x) / 2); label.setAttribute("y", (source.y + target.y) / 2);
|
||||||
|
});
|
||||||
|
nodeLayer.querySelectorAll(".node").forEach(group => {
|
||||||
|
const node = group.__data__; group.setAttribute("transform", `translate(${node.x},${node.y})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNode(id) {
|
||||||
|
state.selected = id;
|
||||||
|
state.connected = new Set([id]);
|
||||||
|
for (const edge of state.edges) {
|
||||||
|
if (edge.source === id) state.connected.add(edge.target);
|
||||||
|
if (edge.target === id) state.connected.add(edge.source);
|
||||||
|
}
|
||||||
|
renderClasses();
|
||||||
|
renderDetails(state.nodeMap.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFocus() {
|
||||||
|
state.selected = null; state.connected.clear();
|
||||||
|
document.getElementById("detail").classList.add("hidden");
|
||||||
|
document.getElementById("detail-empty").classList.remove("hidden");
|
||||||
|
renderClasses();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderClasses() {
|
||||||
|
nodeLayer.querySelectorAll(".node").forEach(group => {
|
||||||
|
const id = group.dataset.id;
|
||||||
|
group.classList.toggle("focused", id === state.selected);
|
||||||
|
group.classList.toggle("dimmed", state.selected !== null && !state.connected.has(id));
|
||||||
|
group.classList.toggle("match", state.matches.has(id));
|
||||||
|
});
|
||||||
|
edgeLayer.querySelectorAll(".edge").forEach(line => {
|
||||||
|
const edge = line.__data__;
|
||||||
|
const connected = state.selected && (edge.source === state.selected || edge.target === state.selected);
|
||||||
|
line.classList.toggle("focused", Boolean(connected));
|
||||||
|
line.classList.toggle("dimmed", state.selected !== null && !connected);
|
||||||
|
});
|
||||||
|
edgeLabelLayer.querySelectorAll(".edge-label").forEach(label => {
|
||||||
|
const edge = label.__data__;
|
||||||
|
label.classList.toggle("focused", Boolean(state.selected && (edge.source === state.selected || edge.target === state.selected)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSearch() {
|
||||||
|
state.matches.clear();
|
||||||
|
if (state.search) for (const node of state.nodes) {
|
||||||
|
const haystack = `${node.label} ${node.type} ${node.severity || ""} ${JSON.stringify(node.info || {})}`.toLowerCase();
|
||||||
|
if (haystack.includes(state.search)) state.matches.add(node.id);
|
||||||
|
}
|
||||||
|
document.getElementById("search-count").textContent = state.search ? String(state.matches.size) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetails(node) {
|
||||||
|
if (!node) return;
|
||||||
|
document.getElementById("detail-empty").classList.add("hidden");
|
||||||
|
document.getElementById("detail").classList.remove("hidden");
|
||||||
|
const style = TYPE_STYLE[node.type] || { icon: "?" };
|
||||||
|
document.getElementById("detail-icon").textContent = style.icon;
|
||||||
|
document.getElementById("detail-title").textContent = node.label;
|
||||||
|
const type = document.getElementById("detail-type");
|
||||||
|
type.textContent = node.severity ? `${node.type} · ${node.severity}` : node.type;
|
||||||
|
type.className = `pill ${node.severity || ""}`;
|
||||||
|
|
||||||
|
const links = document.getElementById("detail-links"); links.replaceChildren();
|
||||||
|
const urls = findUrls(node.info);
|
||||||
|
urls.slice(0, 8).forEach((candidate, index) => links.append(referenceLink(candidate, index)));
|
||||||
|
if (urls.length > 8) links.append(htmlEl("span", "link-overflow", `+${urls.length - 8} more below`));
|
||||||
|
const copyAll = htmlEl("button", "copy-button", "copy evidence");
|
||||||
|
copyAll.type = "button";
|
||||||
|
copyAll.addEventListener("click", () => copyText(JSON.stringify(node.info || {}, null, 2), copyAll));
|
||||||
|
links.append(copyAll);
|
||||||
|
const props = document.getElementById("detail-properties"); props.replaceChildren();
|
||||||
|
appendPropertyGroups(props, node.info || {});
|
||||||
|
const relations = document.getElementById("detail-relations"); relations.replaceChildren();
|
||||||
|
for (const edge of state.edges.filter(edge => edge.source === node.id || edge.target === node.id)) {
|
||||||
|
const otherId = edge.source === node.id ? edge.target : edge.source;
|
||||||
|
const other = state.nodeMap.get(otherId); if (!other) continue;
|
||||||
|
const row = htmlEl("div", "relation");
|
||||||
|
row.append(htmlEl("span", "relation-type", edge.type), htmlEl("span", "", other.label));
|
||||||
|
row.addEventListener("click", () => selectNode(otherId));
|
||||||
|
relations.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function referenceLink(candidate, index = 0) {
|
||||||
|
const url = new URL(candidate);
|
||||||
|
let label = url.hostname.replace(/^www\./, "");
|
||||||
|
if (url.hostname.includes("nvd.nist.gov")) label = "NVD record";
|
||||||
|
else if (url.hostname.includes("cve.org")) label = "CVE record";
|
||||||
|
else if (url.hostname.includes("cwe.mitre.org")) label = "CWE / MITRE";
|
||||||
|
else if (url.hostname.includes("owasp.org")) label = "OWASP reference";
|
||||||
|
else if (index > 0) label = truncate(label, 20);
|
||||||
|
const anchor = htmlEl("a", "pill", label);
|
||||||
|
anchor.href = candidate; anchor.target = "_blank"; anchor.rel = "noopener noreferrer";
|
||||||
|
anchor.title = candidate;
|
||||||
|
return anchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendPropertyGroups(parent, info) {
|
||||||
|
if (!info || typeof info !== "object" || Array.isArray(info)) return appendProperties(parent, info);
|
||||||
|
const entries = Object.entries(info);
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
const details = htmlEl("details", "property-group");
|
||||||
|
details.open = ["evidence", "references", "standards"].includes(key.toLowerCase());
|
||||||
|
const summary = htmlEl("summary", "property-group-title");
|
||||||
|
summary.append(htmlEl("span", "", labelFor(key)), htmlEl("span", "group-count", collectionCount(value)));
|
||||||
|
details.append(summary);
|
||||||
|
const content = htmlEl("div", "property-group-content");
|
||||||
|
if (Array.isArray(value) && value.every(item => typeof item === "string" && /^https?:\/\//i.test(item))) appendUrlPages(content, value);
|
||||||
|
else appendProperties(content, value);
|
||||||
|
details.append(content); parent.append(details);
|
||||||
|
} else appendProperties(parent, value, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendProperties(parent, value, prefix = "") {
|
||||||
|
if (value === null || value === undefined) return;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.every(item => typeof item === "string" && /^https?:\/\//i.test(item))) {
|
||||||
|
appendUrlPages(parent, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
value.forEach((item, index) => appendProperties(parent, item, `${prefix}[${index + 1}]`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value === "object") {
|
||||||
|
for (const [key, item] of Object.entries(value)) appendProperties(parent, item, prefix ? `${prefix}.${key}` : key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = htmlEl("div", "property");
|
||||||
|
const heading = htmlEl("div", "property-key");
|
||||||
|
heading.append(htmlEl("span", "", prefix));
|
||||||
|
const copy = htmlEl("button", "property-copy", "copy");
|
||||||
|
copy.type = "button"; copy.addEventListener("click", () => copyText(String(value), copy)); heading.append(copy);
|
||||||
|
const rendered = htmlEl("div", "property-value");
|
||||||
|
if (typeof value === "string" && /^https?:\/\//i.test(value)) rendered.append(referenceLink(value));
|
||||||
|
else rendered.textContent = String(value);
|
||||||
|
row.append(heading, rendered);
|
||||||
|
parent.append(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendUrlPages(parent, values) {
|
||||||
|
const unique = [...new Set(values)];
|
||||||
|
const pageSize = 20; let shown = Math.min(pageSize, unique.length);
|
||||||
|
const list = htmlEl("div", "url-list");
|
||||||
|
const paint = () => {
|
||||||
|
list.replaceChildren();
|
||||||
|
unique.slice(0, shown).forEach((url, index) => {
|
||||||
|
const row = htmlEl("div", "url-row");
|
||||||
|
row.append(htmlEl("span", "url-index", String(index + 1)), referenceLink(url, index));
|
||||||
|
const copy = htmlEl("button", "property-copy", "copy"); copy.type = "button";
|
||||||
|
copy.addEventListener("click", () => copyText(url, copy)); row.append(copy); list.append(row);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
paint(); parent.append(list);
|
||||||
|
if (unique.length > shown) {
|
||||||
|
const more = htmlEl("button", "load-more", `show next ${Math.min(pageSize, unique.length - shown)} of ${unique.length}`);
|
||||||
|
more.type = "button"; more.addEventListener("click", () => {
|
||||||
|
shown = Math.min(unique.length, shown + pageSize); paint();
|
||||||
|
more.textContent = shown < unique.length ? `show next ${Math.min(pageSize, unique.length - shown)} of ${unique.length}` : "all URLs shown";
|
||||||
|
more.disabled = shown >= unique.length;
|
||||||
|
}); parent.append(more);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectionCount(value) {
|
||||||
|
if (Array.isArray(value)) return `${value.length} items`;
|
||||||
|
if (value && typeof value === "object") return `${Object.keys(value).length} fields`;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyText(value, button) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
const previous = button.textContent; button.textContent = "copied";
|
||||||
|
setTimeout(() => { button.textContent = previous; }, 1100);
|
||||||
|
} catch (_) { button.textContent = "copy failed"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function findUrls(value, found = new Set()) {
|
||||||
|
if (typeof value === "string" && /^https?:\/\//i.test(value)) found.add(value);
|
||||||
|
else if (Array.isArray(value)) value.forEach(item => findUrls(item, found));
|
||||||
|
else if (value && typeof value === "object") Object.values(value).forEach(item => findUrls(item, found));
|
||||||
|
return [...found];
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTooltip(event, node) {
|
||||||
|
tooltip.textContent = `${node.label}\n${labelFor(node.type)}${node.severity ? ` · ${node.severity}` : ""}\nClick to inspect relationships`;
|
||||||
|
tooltip.classList.add("visible"); moveTooltip(event);
|
||||||
|
}
|
||||||
|
function moveTooltip(event) { tooltip.style.left = `${event.clientX + 14}px`; tooltip.style.top = `${event.clientY + 12}px`; }
|
||||||
|
function hideTooltip() { tooltip.classList.remove("visible"); }
|
||||||
|
|
||||||
|
function fitView(animate = true) {
|
||||||
|
const graph = visibleGraph(); if (!graph.nodes.length) return;
|
||||||
|
const xs = graph.nodes.map(node => node.x), ys = graph.nodes.map(node => node.y);
|
||||||
|
const minX = Math.min(...xs) - 55, maxX = Math.max(...xs) + 55, minY = Math.min(...ys) - 55, maxY = Math.max(...ys) + 55;
|
||||||
|
const { width, height } = dimensions();
|
||||||
|
const k = clamp(Math.min(width / Math.max(1, maxX - minX), height / Math.max(1, maxY - minY)) * .9, .12, 2.2);
|
||||||
|
const next = { x: width / 2 - ((minX + maxX) / 2) * k, y: height / 2 - ((minY + maxY) / 2) * k, k };
|
||||||
|
if (!animate) { state.transform = next; applyTransform(); return; }
|
||||||
|
const start = { ...state.transform }, started = performance.now();
|
||||||
|
const frame = now => {
|
||||||
|
const t = Math.min(1, (now - started) / 280), eased = 1 - Math.pow(1 - t, 3);
|
||||||
|
state.transform = { x: lerp(start.x, next.x, eased), y: lerp(start.y, next.y, eased), k: lerp(start.k, next.k, eased) };
|
||||||
|
applyTransform(); if (t < 1) requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerOnNode(node) {
|
||||||
|
const { width, height } = dimensions();
|
||||||
|
const next = { x: width / 2 - node.x * Math.max(1.35, state.transform.k), y: height / 2 - node.y * Math.max(1.35, state.transform.k), k: Math.max(1.35, state.transform.k) };
|
||||||
|
const start = { ...state.transform }, started = performance.now();
|
||||||
|
const frame = now => {
|
||||||
|
const t = Math.min(1, (now - started) / 240), eased = 1 - Math.pow(1 - t, 3);
|
||||||
|
state.transform = { x: lerp(start.x, next.x, eased), y: lerp(start.y, next.y, eased), k: lerp(start.k, next.k, eased) };
|
||||||
|
applyTransform(); if (t < 1) requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTransform() { viewport.setAttribute("transform", `translate(${state.transform.x},${state.transform.y}) scale(${state.transform.k})`); }
|
||||||
|
function svgPoint(clientX, clientY) { const rect = svg.getBoundingClientRect(); return { x: (clientX - rect.left - state.transform.x) / state.transform.k, y: (clientY - rect.top - state.transform.y) / state.transform.k }; }
|
||||||
|
function dimensions() { const rect = svg.getBoundingClientRect(); return { width: Math.max(400, rect.width), height: Math.max(320, rect.height) }; }
|
||||||
|
function radius(node) { return TYPE_STYLE[node.type]?.size || 10; }
|
||||||
|
function truncate(value, length) { value = String(value || ""); return value.length > length ? value.slice(0, length - 1) + "…" : value; }
|
||||||
|
function labelFor(value) { return String(value).replaceAll("_", " ").replace(/\b\w/g, letter => letter.toUpperCase()); }
|
||||||
|
function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); }
|
||||||
|
function lerp(a, b, t) { return a + (b - a) * t; }
|
||||||
|
|
||||||
|
boot().catch(error => {
|
||||||
|
document.getElementById("run-id").textContent = "Unable to load run";
|
||||||
|
document.getElementById("graph-status").textContent = "error";
|
||||||
|
document.getElementById("empty-state").textContent = error.message;
|
||||||
|
document.getElementById("empty-state").classList.remove("hidden");
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>REDflare Visual Console</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand"><span class="brand-mark">RF</span><div><strong>REDflare</strong><small>Visual investigation console</small></div></div>
|
||||||
|
<div class="run-meta"><span id="run-id">Loading run…</span><span id="graph-status" class="status">offline data</span></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="workspace">
|
||||||
|
<aside class="sidebar left-panel">
|
||||||
|
<section class="card stats-card">
|
||||||
|
<div class="card-title">Run topology</div>
|
||||||
|
<div id="stats" class="stats-grid"></div>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<label class="search-label" for="graph-search">Search graph</label>
|
||||||
|
<div class="search-wrap"><input id="graph-search" type="search" placeholder="URL, parameter, finding, test ID…"><span id="search-count"></span></div>
|
||||||
|
</section>
|
||||||
|
<section class="card grow">
|
||||||
|
<div class="card-title row"><span>Layers</span><button id="show-all" class="text-button">show all</button></div>
|
||||||
|
<div id="filters" class="filters"></div>
|
||||||
|
</section>
|
||||||
|
<section class="card legend-card">
|
||||||
|
<div class="card-title">Severity</div>
|
||||||
|
<div class="severity-legend"><span class="critical">critical</span><span class="high">high</span><span class="medium">medium</span><span class="low">low</span><span class="info">info</span></div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="graph-shell">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="segmented" aria-label="Graph layout">
|
||||||
|
<button class="layout-button active" data-layout="force">Force</button>
|
||||||
|
<button class="layout-button" data-layout="tree">Tree</button>
|
||||||
|
<button class="layout-button" data-layout="radial">Radial</button>
|
||||||
|
</div>
|
||||||
|
<button id="fit-view" class="tool-button">Fit view</button>
|
||||||
|
<button id="reset-focus" class="tool-button">Clear focus</button>
|
||||||
|
<span class="shortcut-hint">/ search · F fit · Esc clear · double-click center</span>
|
||||||
|
<span id="visible-count" class="visible-count"></span>
|
||||||
|
</div>
|
||||||
|
<div id="graph-stage" class="graph-stage">
|
||||||
|
<svg id="graph" role="img" aria-label="REDflare assessment graph">
|
||||||
|
<defs>
|
||||||
|
<marker id="arrow" viewBox="0 -5 10 10" refX="18" refY="0" markerWidth="5" markerHeight="5" orient="auto"><path d="M0,-5L10,0L0,5"></path></marker>
|
||||||
|
<filter id="glow"><feGaussianBlur stdDeviation="3" result="blur"></feGaussianBlur><feMerge><feMergeNode in="blur"></feMergeNode><feMergeNode in="SourceGraphic"></feMergeNode></feMerge></filter>
|
||||||
|
</defs>
|
||||||
|
<g id="viewport"><g id="edges"></g><g id="edge-labels"></g><g id="nodes"></g></g>
|
||||||
|
</svg>
|
||||||
|
<div id="tooltip" class="tooltip"></div>
|
||||||
|
<div id="empty-state" class="empty-state hidden">No nodes match the current filters.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="sidebar detail-panel">
|
||||||
|
<section class="card detail-card">
|
||||||
|
<div class="card-title">Evidence inspector</div>
|
||||||
|
<div id="detail-empty" class="detail-empty"><div class="detail-icon">⌖</div><p>Select a node to inspect its evidence and relationships.</p></div>
|
||||||
|
<div id="detail" class="hidden">
|
||||||
|
<div class="detail-heading"><span id="detail-icon"></span><div><h2 id="detail-title"></h2><span id="detail-type" class="pill"></span></div></div>
|
||||||
|
<div id="detail-links" class="detail-links"></div>
|
||||||
|
<div class="detail-section"><h3>Properties</h3><div id="detail-properties"></div></div>
|
||||||
|
<div class="detail-section"><h3>Relationships</h3><div id="detail-relations"></div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #07090d;
|
||||||
|
--panel: #10141b;
|
||||||
|
--panel-2: #151b24;
|
||||||
|
--border: #293241;
|
||||||
|
--text: #edf2f7;
|
||||||
|
--muted: #8491a3;
|
||||||
|
--red: #ff4d5f;
|
||||||
|
--cyan: #4ad9d9;
|
||||||
|
--orange: #f59e42;
|
||||||
|
--purple: #ac7cff;
|
||||||
|
--green: #4fd18b;
|
||||||
|
--blue: #58a6ff;
|
||||||
|
--yellow: #e8c547;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: var(--bg); color: var(--text); font: 13px/1.45 Inter, ui-sans-serif, system-ui, sans-serif; }
|
||||||
|
body::before { content: ""; position: fixed; inset: 0; pointer-events: none; background: radial-gradient(circle at 50% 20%, rgba(74,217,217,.055), transparent 35%), linear-gradient(rgba(255,255,255,.012) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.012) 1px, transparent 1px); background-size: auto, 34px 34px, 34px 34px; }
|
||||||
|
button, input { font: inherit; }
|
||||||
|
button { color: inherit; }
|
||||||
|
.topbar { height: 60px; padding: 0 18px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); background: rgba(8,11,16,.96); position: relative; z-index: 4; }
|
||||||
|
.brand { display: flex; align-items: center; gap: 11px; letter-spacing: .02em; }
|
||||||
|
.brand-mark { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid rgba(255,77,95,.65); border-radius: 8px; color: var(--red); font-weight: 800; box-shadow: 0 0 20px rgba(255,77,95,.12); }
|
||||||
|
.brand strong { font-size: 16px; }
|
||||||
|
.brand small { display: block; color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .12em; }
|
||||||
|
.run-meta { display: flex; align-items: center; gap: 12px; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
|
.status { color: var(--green); border: 1px solid rgba(79,209,139,.3); border-radius: 99px; padding: 3px 8px; font-size: 10px; text-transform: uppercase; }
|
||||||
|
.workspace { height: calc(100vh - 60px); display: grid; grid-template-columns: 238px minmax(420px, 1fr) 330px; position: relative; z-index: 1; }
|
||||||
|
.sidebar { min-height: 0; padding: 12px; display: flex; flex-direction: column; gap: 10px; background: rgba(9,12,17,.94); }
|
||||||
|
.left-panel { border-right: 1px solid var(--border); }
|
||||||
|
.detail-panel { border-left: 1px solid var(--border); }
|
||||||
|
.card { border: 1px solid var(--border); border-radius: 9px; background: linear-gradient(145deg, rgba(21,27,36,.94), rgba(13,17,24,.94)); padding: 12px; min-height: 0; }
|
||||||
|
.card.grow, .detail-card { flex: 1; overflow: auto; }
|
||||||
|
.card-title { color: #b9c4d2; font-size: 10px; letter-spacing: .12em; text-transform: uppercase; font-weight: 700; margin-bottom: 10px; }
|
||||||
|
.card-title.row { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.stat { padding: 8px; border-radius: 7px; background: rgba(255,255,255,.025); border: 1px solid rgba(255,255,255,.04); }
|
||||||
|
.stat strong { display: block; font-size: 18px; color: var(--cyan); }
|
||||||
|
.stat span { color: var(--muted); font-size: 10px; text-transform: uppercase; }
|
||||||
|
.search-label { color: var(--muted); display: block; margin-bottom: 6px; font-size: 11px; }
|
||||||
|
.search-wrap { position: relative; }
|
||||||
|
#graph-search { width: 100%; height: 34px; border-radius: 7px; border: 1px solid var(--border); background: #090c11; color: var(--text); padding: 0 46px 0 10px; outline: none; }
|
||||||
|
#graph-search:focus { border-color: rgba(74,217,217,.7); box-shadow: 0 0 0 2px rgba(74,217,217,.08); }
|
||||||
|
#search-count { position: absolute; right: 8px; top: 8px; color: var(--cyan); font: 10px ui-monospace, monospace; }
|
||||||
|
.filters { display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
.filter-row { display: grid; grid-template-columns: 16px 13px 1fr auto; align-items: center; gap: 7px; padding: 5px 4px; border-radius: 5px; color: #b8c2d0; }
|
||||||
|
.filter-row:hover { background: rgba(255,255,255,.03); }
|
||||||
|
.filter-row input { accent-color: var(--cyan); }
|
||||||
|
.swatch { width: 9px; height: 9px; border-radius: 50%; box-shadow: 0 0 8px currentColor; }
|
||||||
|
.filter-count { color: var(--muted); font: 10px ui-monospace, monospace; }
|
||||||
|
.text-button { background: none; border: 0; color: var(--cyan); cursor: pointer; font-size: 10px; }
|
||||||
|
.severity-legend { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||||
|
.severity-legend span, .pill { padding: 3px 7px; border-radius: 99px; border: 1px solid currentColor; font-size: 9px; text-transform: uppercase; }
|
||||||
|
.critical { color: #ff4055; }.high { color: #ff874d; }.medium { color: #e8c547; }.low { color: #58a6ff; }.info { color: #8491a3; }
|
||||||
|
.graph-shell { min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.toolbar { height: 48px; flex: 0 0 48px; display: flex; align-items: center; gap: 9px; padding: 0 12px; border-bottom: 1px solid var(--border); background: rgba(12,16,22,.86); }
|
||||||
|
.segmented { display: flex; padding: 3px; border: 1px solid var(--border); border-radius: 7px; background: #090c11; }
|
||||||
|
.layout-button, .tool-button { border: 0; background: transparent; color: var(--muted); padding: 6px 10px; border-radius: 5px; cursor: pointer; }
|
||||||
|
.layout-button.active { background: rgba(74,217,217,.13); color: var(--cyan); }
|
||||||
|
.tool-button { border: 1px solid var(--border); color: #aeb9c7; }
|
||||||
|
.tool-button:hover { border-color: #475569; color: white; }
|
||||||
|
.visible-count { margin-left: auto; color: var(--muted); font: 10px ui-monospace, monospace; }
|
||||||
|
.graph-stage { flex: 1; min-height: 0; position: relative; overflow: hidden; }
|
||||||
|
#graph { width: 100%; height: 100%; display: block; cursor: grab; user-select: none; }
|
||||||
|
#graph.panning { cursor: grabbing; }
|
||||||
|
#viewport { transform-origin: 0 0; }
|
||||||
|
#arrow path { fill: rgba(132,145,163,.55); }
|
||||||
|
.edge { stroke: rgba(132,145,163,.22); stroke-width: 1.1; marker-end: url(#arrow); transition: opacity .15s, stroke .15s; }
|
||||||
|
.edge.focused { stroke: var(--cyan); stroke-width: 2; opacity: .9; }
|
||||||
|
.edge.dimmed { opacity: .07; }
|
||||||
|
.edge-label { fill: #8a9aae; font: 8px ui-monospace, monospace; pointer-events: none; opacity: 0; transition: opacity .15s; paint-order: stroke; stroke: #07090d; stroke-width: 3px; }
|
||||||
|
.edge-label.focused { opacity: 1; }
|
||||||
|
.node { cursor: pointer; transition: opacity .15s; }
|
||||||
|
.node.dimmed { opacity: .1; }
|
||||||
|
.node.match .node-ring { stroke: white; stroke-width: 3; filter: url(#glow); }
|
||||||
|
.node.focused .node-ring { stroke: var(--cyan); stroke-width: 3; filter: url(#glow); }
|
||||||
|
.node-circle { stroke: rgba(255,255,255,.38); stroke-width: 1; }
|
||||||
|
.node-ring { fill: none; stroke: transparent; stroke-width: 0; }
|
||||||
|
.node-icon { fill: #071015; font-size: 10px; font-weight: 900; text-anchor: middle; dominant-baseline: central; pointer-events: none; }
|
||||||
|
.node-label { fill: #dce3ec; font: 10px ui-sans-serif, system-ui; text-anchor: middle; pointer-events: none; paint-order: stroke; stroke: #07090d; stroke-width: 3px; stroke-linejoin: round; }
|
||||||
|
.graph-stage.dense .node[data-type="endpoint"] .node-label { opacity: 0; }
|
||||||
|
.graph-stage.dense .node[data-type="endpoint"]:hover .node-label,
|
||||||
|
.graph-stage.dense .node[data-type="endpoint"].focused .node-label,
|
||||||
|
.graph-stage.dense .node[data-type="endpoint"].match .node-label { opacity: 1; }
|
||||||
|
.node-badge { fill: #071015; stroke: #ff4055; stroke-width: 1; }
|
||||||
|
.node-badge-text { fill: #ff6b7b; font: 8px ui-monospace, monospace; text-anchor: middle; dominant-baseline: central; }
|
||||||
|
.tooltip { position: fixed; display: none; max-width: 340px; padding: 9px 11px; border: 1px solid #405067; border-radius: 7px; background: rgba(7,10,15,.97); box-shadow: 0 10px 30px rgba(0,0,0,.45); color: #dce3ec; pointer-events: none; z-index: 20; white-space: pre-line; font-size: 11px; }
|
||||||
|
.tooltip.visible { display: block; }
|
||||||
|
.empty-state { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); pointer-events: none; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
.detail-empty { height: 100%; min-height: 240px; display: grid; align-content: center; justify-items: center; color: var(--muted); text-align: center; }
|
||||||
|
.detail-icon { font-size: 36px; color: #3e4c60; }
|
||||||
|
.detail-heading { display: flex; gap: 11px; align-items: flex-start; border-bottom: 1px solid var(--border); padding-bottom: 12px; }
|
||||||
|
#detail-icon { width: 38px; height: 38px; border-radius: 9px; display: grid; place-items: center; background: rgba(74,217,217,.1); color: var(--cyan); font-size: 18px; }
|
||||||
|
.detail-heading h2 { margin: 0 0 6px; font-size: 15px; overflow-wrap: anywhere; }
|
||||||
|
.detail-links { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 0 0; }
|
||||||
|
.detail-links a { text-decoration: none; }
|
||||||
|
.link-overflow { color: var(--muted); font-size: 10px; align-self: center; }
|
||||||
|
.copy-button, .load-more { border: 1px solid #334155; border-radius: 99px; background: #0a0e14; color: var(--cyan); padding: 4px 8px; cursor: pointer; font-size: 9px; text-transform: uppercase; }
|
||||||
|
.copy-button:hover, .load-more:hover { border-color: var(--cyan); }
|
||||||
|
.detail-section { margin-top: 16px; }
|
||||||
|
.detail-section h3 { margin: 0 0 7px; color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .12em; }
|
||||||
|
.property { padding: 7px 0; border-bottom: 1px solid rgba(41,50,65,.6); }
|
||||||
|
.property-key { color: #78869a; font-size: 10px; text-transform: uppercase; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.property-value { color: #d4dde8; overflow-wrap: anywhere; white-space: pre-wrap; font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
|
.property-value a { color: #83d9ff; text-decoration: none; text-transform: none; }
|
||||||
|
.property-copy { border: 0; background: none; color: #617087; cursor: pointer; font-size: 9px; text-transform: uppercase; }
|
||||||
|
.property-copy:hover { color: var(--cyan); }
|
||||||
|
.property-group { border-bottom: 1px solid rgba(41,50,65,.6); }
|
||||||
|
.property-group-title { cursor: pointer; padding: 9px 0; color: #aeb9c7; font-size: 10px; text-transform: uppercase; display: flex; justify-content: space-between; }
|
||||||
|
.property-group-title::marker { color: var(--cyan); }
|
||||||
|
.group-count { color: #617087; font: 9px ui-monospace, monospace; text-transform: none; }
|
||||||
|
.property-group-content { padding-left: 10px; border-left: 1px solid rgba(74,217,217,.16); margin-bottom: 8px; }
|
||||||
|
.url-row { display: grid; grid-template-columns: 24px minmax(0,1fr) auto; align-items: center; gap: 6px; padding: 5px 0; border-bottom: 1px solid rgba(41,50,65,.45); }
|
||||||
|
.url-row a { color: #83d9ff; overflow-wrap: anywhere; text-decoration: none; font: 10px/1.35 ui-monospace, monospace; text-transform: none; }
|
||||||
|
.url-index { color: #566476; font: 9px ui-monospace, monospace; }
|
||||||
|
.load-more { margin: 8px 0; border-radius: 5px; width: 100%; }
|
||||||
|
.relation { display: flex; align-items: center; gap: 7px; padding: 5px 0; color: #b9c4d2; cursor: pointer; }
|
||||||
|
.relation:hover { color: var(--cyan); }
|
||||||
|
.relation-type { color: #68778b; font-size: 9px; text-transform: uppercase; }
|
||||||
|
.shortcut-hint { color: #566476; font-size: 9px; margin-left: 4px; }
|
||||||
|
@media (max-width: 1050px) { .workspace { grid-template-columns: 210px minmax(360px,1fr) 280px; } }
|
||||||
Reference in New Issue
Block a user