mirror of
https://github.com/q3alique/codeflow
synced 2026-06-21 14:06:02 +00:00
6c91bf5099
Root cause: _tag_svg_id regex matched data-flow-id as a standalone id= attribute, so flow panel SVGs never got an id attribute injected. document.getElementById(svgId) returned null, silently no-oping all zoom, pan, drag and fitView calls. Only the overview panel worked. Fixes: - _tag_svg_id: require whitespace before id= so data-flow-id does not false-positive - Wrap SVG in .svg-canvas div; applyVp sizes canvas to (svgW x scale) so scrollbar range correctly reflects zoomed content - .svg-wrap: overflow:auto -- native scrollbars when content > viewport - Zoom via CSS scale on svg, pan via scrollLeft/scrollTop (scroll-anchored on cursor for wheel zoom) - Thin styled scrollbars (dark + light theme) - Print beforeprint/afterprint updated for canvas wrapper
1610 lines
63 KiB
Python
1610 lines
63 KiB
Python
from __future__ import annotations
|
||
import json
|
||
import re
|
||
from pathlib import Path
|
||
from codeflow.models.graph import CodeGraph
|
||
from codeflow.models.flow import Flow
|
||
from codeflow.models.edge import EdgeType
|
||
|
||
|
||
# ── Severity colours ──────────────────────────────────────────────────────────
|
||
|
||
_SEV_COLOR = {
|
||
"CRITICAL": "#e53e3e",
|
||
"HIGH": "#dd6b20",
|
||
"MEDIUM": "#d69e2e",
|
||
"LOW": "#68d391",
|
||
"INFO": "#718096",
|
||
}
|
||
|
||
|
||
# ── Embedded CSS ──────────────────────────────────────────────────────────────
|
||
|
||
EMBEDDED_CSS = """
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
background: #0b1120; color: #e2e8f0;
|
||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||
display: flex; flex-direction: column; height: 100vh; overflow: hidden;
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════
|
||
TOP BAR — logo + stats chips + actions
|
||
══════════════════════════════════════════════════════════ */
|
||
.topbar {
|
||
background: #111827;
|
||
border-bottom: 1px solid #1f2d40;
|
||
padding: 0 18px;
|
||
height: 52px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
flex-shrink: 0;
|
||
}
|
||
.topbar-brand {
|
||
display: flex; align-items: center; gap: 9px; flex-shrink: 0;
|
||
}
|
||
.topbar-logo {
|
||
width: 28px; height: 28px; border-radius: 7px;
|
||
background: linear-gradient(135deg, #3b82f6 0%, #7c3aed 100%);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 14px; font-weight: 900; color: white; letter-spacing: -1px;
|
||
flex-shrink: 0;
|
||
}
|
||
.topbar-title {
|
||
font-size: 14px; font-weight: 700; color: #e2e8f0; white-space: nowrap;
|
||
}
|
||
.topbar-repo {
|
||
font-size: 12px; color: #475569; font-weight: 400;
|
||
}
|
||
.topbar-divider {
|
||
width: 1px; height: 24px; background: #1f2d40; flex-shrink: 0;
|
||
}
|
||
|
||
/* Stat chips in topbar */
|
||
.stat-chips { display: flex; gap: 6px; align-items: center; }
|
||
.stat-chip {
|
||
display: flex; align-items: center; gap: 5px;
|
||
background: #1e293b; border: 1px solid #2d3a50;
|
||
border-radius: 16px; padding: 3px 10px;
|
||
font-size: 11px; color: #94a3b8; white-space: nowrap;
|
||
}
|
||
.stat-chip .val { font-weight: 700; color: #e2e8f0; }
|
||
.stat-chip.danger .val { color: #fc8181; }
|
||
.stat-chip.warn .val { color: #fbd38d; }
|
||
.stat-chip.ok .val { color: #68d391; }
|
||
|
||
/* Action buttons */
|
||
.topbar-actions { margin-left: auto; display: flex; align-items: center; gap: 6px; }
|
||
.act-btn {
|
||
display: flex; align-items: center; gap: 5px;
|
||
padding: 5px 11px; border-radius: 6px; cursor: pointer;
|
||
font-size: 11px; font-weight: 600; letter-spacing: .3px;
|
||
border: 1px solid #2d3a50; background: #1e293b; color: #94a3b8;
|
||
transition: all .14s; white-space: nowrap;
|
||
}
|
||
.act-btn:hover { background: #2d3a50; color: #e2e8f0; border-color: #4a5568; }
|
||
.act-btn.primary { background: #1d4ed8; border-color: #3b82f6; color: white; }
|
||
.act-btn.primary:hover { background: #2563eb; }
|
||
|
||
/* Search box in topbar */
|
||
#flow-search {
|
||
background: #1e293b; border: 1px solid #2d3a50; color: #e2e8f0;
|
||
border-radius: 6px; padding: 5px 11px; font-size: 12px; width: 180px;
|
||
outline: none; transition: border-color .14s;
|
||
}
|
||
#flow-search:focus { border-color: #3b82f6; background: #162032; }
|
||
#flow-search::placeholder { color: #334155; }
|
||
|
||
/* ══════════════════════════════════════════════════════════
|
||
FLOW NAV — scrollable pill strip
|
||
══════════════════════════════════════════════════════════ */
|
||
.flow-nav {
|
||
background: #0d1526;
|
||
border-bottom: 1px solid #1a2540;
|
||
padding: 8px 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
overflow-x: auto;
|
||
flex-shrink: 0;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: #2d3a50 transparent;
|
||
}
|
||
.flow-nav::-webkit-scrollbar { height: 3px; }
|
||
.flow-nav::-webkit-scrollbar-track { background: transparent; }
|
||
.flow-nav::-webkit-scrollbar-thumb { background: #2d3a50; border-radius: 2px; }
|
||
|
||
.flow-pill {
|
||
display: flex; align-items: center; gap: 7px;
|
||
padding: 5px 13px 5px 9px;
|
||
border-radius: 8px;
|
||
border: 1px solid #1f2d40;
|
||
cursor: pointer; font-size: 12px; color: #4a5568;
|
||
background: #111827;
|
||
transition: all .14s; white-space: nowrap; flex-shrink: 0;
|
||
position: relative;
|
||
}
|
||
.flow-pill::before {
|
||
/* Left severity bar */
|
||
content: ''; display: block;
|
||
width: 3px; height: 20px; border-radius: 2px;
|
||
background: var(--sev-color, #334155);
|
||
flex-shrink: 0;
|
||
}
|
||
.flow-pill:hover {
|
||
background: #172033; border-color: #2d3a50; color: #94a3b8;
|
||
}
|
||
.flow-pill.active {
|
||
background: #1e293b; border-color: #3b4f6b;
|
||
color: #e2e8f0;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,.5);
|
||
}
|
||
.flow-pill.active::before { opacity: 1; }
|
||
|
||
.method-chip {
|
||
display: inline-block; font-size: 9px; font-weight: 800;
|
||
padding: 1px 5px; border-radius: 3px; letter-spacing: .6px;
|
||
color: white; flex-shrink: 0;
|
||
}
|
||
.pill-name { font-weight: 500; }
|
||
.pill-sev {
|
||
font-size: 9px; font-weight: 700; letter-spacing: .4px;
|
||
padding: 1px 5px; border-radius: 3px;
|
||
color: white; background: var(--sev-color, #334155);
|
||
flex-shrink: 0; opacity: 0.9;
|
||
}
|
||
.flow-pill.active .pill-sev { opacity: 1; }
|
||
|
||
/* ══════════════════════════════════════════════════════════
|
||
MAIN LAYOUT
|
||
══════════════════════════════════════════════════════════ */
|
||
.main { display: flex; flex: 1; overflow: hidden; min-height: 0; }
|
||
/* overflow: hidden (not auto) is critical — lets flex children use calc'd height
|
||
so .svg-wrap { flex:1 } actually fills the remaining space instead of 0px */
|
||
.content { flex: 1; overflow: hidden; padding: 12px; display: flex; flex-direction: column; min-height: 0; }
|
||
.content-pane { display: none; flex-direction: column; flex: 1; min-height: 0; }
|
||
.content-pane.active { display: flex; }
|
||
|
||
/* ── Sidebar ── */
|
||
.sidebar {
|
||
width: 290px; background: #111827; border-left: 1px solid #1a2540;
|
||
overflow-y: auto; padding: 12px; flex-shrink: 0; display: flex;
|
||
flex-direction: column; gap: 12px;
|
||
}
|
||
.sidebar h3 { font-size: 13px; color: #64748b; margin-bottom: 6px; }
|
||
.detail-item { margin-bottom: 9px; }
|
||
.detail-label { font-size: 10px; color: #4a5568; text-transform: uppercase; letter-spacing: .5px; }
|
||
.detail-value { font-size: 12px; color: #e2e8f0; word-break: break-all; margin-top: 2px; }
|
||
.tainted-badge {
|
||
display: inline-block; background: #7b341e; color: #fed7aa;
|
||
font-size: 10px; font-weight: 700; padding: 2px 8px;
|
||
border-radius: 10px; letter-spacing: .5px;
|
||
}
|
||
.sev-badge {
|
||
display: inline-block; color: white; font-size: 10px; font-weight: 700;
|
||
padding: 2px 10px; border-radius: 10px; letter-spacing: .5px;
|
||
}
|
||
|
||
/* Code snippet */
|
||
.code-snippet {
|
||
font-family: monospace; font-size: 11px; background: #0b1120;
|
||
border: 1px solid #1f2d40; border-radius: 4px; padding: 6px 0;
|
||
overflow-x: auto; margin-top: 4px; line-height: 1.5;
|
||
}
|
||
.code-line { display: flex; white-space: pre; }
|
||
.code-line.current { background: #1a150a; }
|
||
.line-num {
|
||
display: inline-block; min-width: 30px; text-align: right;
|
||
padding: 0 6px; color: #334155; user-select: none; flex-shrink: 0;
|
||
}
|
||
.line-code { color: #64748b; padding-right: 8px; }
|
||
.code-line.current .line-code { color: #f6ad55; }
|
||
|
||
/* Print node table — invisible on screen, rendered only in @media print */
|
||
.print-node-table { display: none; }
|
||
|
||
/* ── Legend ── */
|
||
.legend { border-top: 1px solid #1f2d40; padding-top: 12px; }
|
||
.legend-item {
|
||
display: flex; align-items: center; gap: 8px;
|
||
font-size: 11px; color: #64748b; margin-bottom: 7px;
|
||
}
|
||
.swatch {
|
||
display: inline-block; width: 18px; height: 12px;
|
||
border-radius: 3px; flex-shrink: 0;
|
||
}
|
||
.swatch-border {
|
||
display: inline-block; width: 18px; height: 12px;
|
||
border-radius: 3px; border: 2px solid #e53e3e; flex-shrink: 0;
|
||
}
|
||
.edge-line { display: inline-block; width: 24px; height: 2px; border-radius: 1px; flex-shrink: 0; }
|
||
.hint { font-size: 10px; color: #334155; line-height: 1.6; }
|
||
.sev-dot {
|
||
display: inline-block; width: 7px; height: 7px;
|
||
border-radius: 50%; flex-shrink: 0;
|
||
}
|
||
|
||
/* ── Stats strip (inside content pane) ── */
|
||
.stats { display: flex; gap: 8px; margin-bottom: 10px; flex-shrink: 0; flex-wrap: wrap; }
|
||
.stat-card {
|
||
background: #111827; padding: 8px 14px; border-radius: 6px;
|
||
border: 1px solid #1f2d40;
|
||
}
|
||
.stat-card .value { font-size: 20px; font-weight: 700; }
|
||
.stat-card .label { font-size: 11px; color: #4a5568; margin-top: 1px; }
|
||
|
||
/* Flow pane meta row */
|
||
.flow-meta { font-size: 12px; color: #4a5568; margin-bottom: 8px; flex-shrink: 0; }
|
||
.flow-meta span { margin-right: 14px; }
|
||
|
||
/* ── SVG wrapper ── */
|
||
.svg-wrap {
|
||
flex: 1; overflow: auto; position: relative;
|
||
border: 1px solid #1f2d40; border-radius: 0 0 6px 6px; background: #111827;
|
||
user-select: none; cursor: grab;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: #2d3a50 transparent;
|
||
}
|
||
.svg-wrap::-webkit-scrollbar { width: 7px; height: 7px; }
|
||
.svg-wrap::-webkit-scrollbar-track { background: transparent; }
|
||
.svg-wrap::-webkit-scrollbar-thumb { background: #2d3a50; border-radius: 4px; }
|
||
.svg-wrap::-webkit-scrollbar-corner { background: transparent; }
|
||
/* Canvas div is sized to (svgW × scale) by JS so scroll range = zoomed content */
|
||
.svg-canvas { display: inline-block; position: relative; }
|
||
.svg-wrap svg { display: block; transform-origin: 0 0; }
|
||
|
||
/* ── SVG element fills — CSS-only, no presentation attributes in SVG markup ──
|
||
Dark values are the defaults; body.light overrides switch to light palette. */
|
||
|
||
/* Swim-lane bands */
|
||
.lane-sink { fill: #3b0a0a; }
|
||
.lane-entry { fill: #0a2018; }
|
||
.lane-input { fill: #261200; }
|
||
.lane-default { fill: #101820; }
|
||
/* Swim-lane labels */
|
||
.lane-lbl-sink { fill: #fc8181; }
|
||
.lane-lbl-entry { fill: #68d391; }
|
||
.lane-lbl-input { fill: #f6ad55; }
|
||
|
||
/* Overview module boxes */
|
||
.module-box-body { fill: #1e293b; stroke: #334155; }
|
||
.module-box-header { fill: #2d3748; }
|
||
.module-header { fill: #a0aec0; }
|
||
.module-name { fill: #e2e8f0; }
|
||
.module-file { fill: #718096; }
|
||
|
||
/* Overview entry-point text */
|
||
.ep-method { fill: #ffffff; }
|
||
.ep-route { fill: #e2e8f0; }
|
||
|
||
/* Flow-view node text */
|
||
.node-label { fill: #e2e8f0; } /* main wrapped label — inside node shape */
|
||
.badge-lbl { fill: #ffffff; } /* type badge text — on coloured pill */
|
||
.node-sub-sink { fill: #fc8181; } /* sink type subtitle */
|
||
.node-sub-src { fill: #f6ad55; } /* source input-type subtitle */
|
||
.node-sub-env { fill: #b794f4; } /* env-var subtitle */
|
||
|
||
/* ── Zoom toolbar ── */
|
||
.zoom-bar {
|
||
display: flex; gap: 4px; padding: 5px 8px; background: #111827;
|
||
border: 1px solid #1f2d40; border-bottom: none;
|
||
flex-shrink: 0; border-radius: 6px 6px 0 0; align-items: center;
|
||
}
|
||
.zoom-btn {
|
||
background: #1e293b; border: 1px solid #2d3a50; color: #94a3b8;
|
||
width: 26px; height: 26px; border-radius: 4px; cursor: pointer;
|
||
font-size: 14px; display: flex; align-items: center; justify-content: center;
|
||
transition: all .1s;
|
||
}
|
||
.zoom-btn:hover { background: #2d3a50; color: #e2e8f0; }
|
||
.zoom-label { color: #475569; font-size: 11px; padding: 0 6px; min-width: 36px; text-align: center; }
|
||
.zoom-hint { color: #2d3a50; font-size: 10px; margin-left: auto; }
|
||
h2 { font-size: 13px; margin-bottom: 6px; color: #64748b; flex-shrink: 0; font-weight: 500; }
|
||
|
||
/* ── Node interaction ── */
|
||
.node { cursor: move; }
|
||
.node:hover rect { stroke-width: 3.5 !important; }
|
||
.node:hover polygon { stroke-width: 3.5 !important; }
|
||
|
||
/* ── Taint-path highlighting ── */
|
||
.taint-active .node { opacity: 0.08; transition: opacity .18s; }
|
||
.taint-active .flow-edge { opacity: 0.04; transition: opacity .18s; }
|
||
.taint-active .node.taint-on { opacity: 1; }
|
||
.taint-active .flow-edge.taint-on { opacity: 1; }
|
||
|
||
/* ══════════════════════════════════════════════════════════
|
||
PRINT / PDF EXPORT — portrait A4, graph on top, node cards below
|
||
══════════════════════════════════════════════════════════ */
|
||
@media print {
|
||
@page { size: A4 portrait; margin: 12mm; }
|
||
|
||
/* Reset body / chrome to flat block flow */
|
||
body { background: #fff !important; color: #0f172a !important;
|
||
overflow: visible !important; height: auto !important;
|
||
display: block !important; }
|
||
|
||
.topbar, .flow-nav, .zoom-bar { display: none !important; }
|
||
|
||
/* Flatten the flex shell — each pane is a plain block */
|
||
.main { display: block !important; overflow: visible !important; }
|
||
.content { display: block !important; overflow: visible !important;
|
||
padding: 0 !important; }
|
||
|
||
.content-pane { display: none !important; }
|
||
.content-pane.active { display: block !important; }
|
||
|
||
/* Hide the interactive sidebar — we embed node detail cards inline */
|
||
.sidebar { display: none !important; }
|
||
|
||
/* Flow heading + meta */
|
||
h2 { color: #0f172a !important; font-size: 14px !important;
|
||
margin: 0 0 3px 0 !important; }
|
||
.flow-meta { color: #475569 !important; font-size: 10px !important;
|
||
margin-bottom: 4px !important; }
|
||
|
||
/* Graph area — fixed height, first page.
|
||
overflow:hidden clips the SVG to the container so nothing bleeds out.
|
||
The beforeprint JS resets canvas size + scale, sets the SVG
|
||
to width/height 100%, letting the viewBox scale the full graph to fit. */
|
||
.svg-wrap {
|
||
display: block !important; overflow: hidden !important;
|
||
height: 148mm !important; border: 1px solid #e2e8f0 !important;
|
||
background: #f8fafc !important; border-radius: 4px !important;
|
||
}
|
||
/* canvas must fill the wrap so SVG 100%/100% resolves correctly */
|
||
.svg-canvas { display: block !important; width: 100% !important; height: 100% !important; }
|
||
.svg-wrap svg { width: 100% !important; height: 100% !important;
|
||
display: block !important; transform: none !important; }
|
||
|
||
/* ── Print node table — shown below the graph ───────────────────────── */
|
||
.print-node-table {
|
||
display: block !important;
|
||
margin-top: 6mm !important;
|
||
}
|
||
.pnt-section-title {
|
||
font-size: 9px !important; font-weight: 700 !important;
|
||
letter-spacing: 1px !important; text-transform: uppercase !important;
|
||
color: #475569 !important; border-bottom: 1px solid #e2e8f0 !important;
|
||
padding-bottom: 2mm !important; margin-bottom: 4mm !important;
|
||
}
|
||
/* Single-column list of node cards */
|
||
.pnt-grid {
|
||
display: flex !important;
|
||
flex-direction: column !important;
|
||
gap: 3mm !important;
|
||
}
|
||
.pnt-card {
|
||
border: 1px solid #e2e8f0 !important; border-radius: 4px !important;
|
||
padding: 2.5mm !important; background: #fff !important;
|
||
page-break-inside: avoid !important;
|
||
-webkit-print-color-adjust: exact; print-color-adjust: exact;
|
||
}
|
||
/* Badges row */
|
||
.pnt-badges { display: flex !important; gap: 1.5mm !important;
|
||
flex-wrap: wrap !important; margin-bottom: 1.5mm !important; }
|
||
.pnt-type, .pnt-tainted, .pnt-sev {
|
||
display: inline-block !important; padding: 1px 4px !important;
|
||
border-radius: 3px !important; font-size: 7px !important;
|
||
font-weight: 700 !important; color: #fff !important;
|
||
letter-spacing: 0.4px !important; text-transform: uppercase !important;
|
||
-webkit-print-color-adjust: exact; print-color-adjust: exact;
|
||
}
|
||
.pnt-tainted { background: #7c3aed !important; }
|
||
/* node label */
|
||
.pnt-label { font-size: 9px !important; font-weight: 600 !important;
|
||
color: #0f172a !important; font-family: monospace !important;
|
||
margin-bottom: 1mm !important; word-break: break-all !important; }
|
||
/* file:line */
|
||
.pnt-file { font-size: 8px !important; color: #64748b !important;
|
||
margin-bottom: 1.5mm !important; }
|
||
/* extra metadata */
|
||
.pnt-meta { margin-bottom: 1.5mm !important; }
|
||
.pnt-meta-row { display: flex !important; gap: 2mm !important;
|
||
font-size: 8px !important; line-height: 1.4 !important; }
|
||
.pnt-meta-key { color: #64748b !important; min-width: 14mm !important;
|
||
flex-shrink: 0 !important; }
|
||
.pnt-meta-val { color: #0f172a !important; word-break: break-all !important; }
|
||
/* inline code snippet inside card */
|
||
.pnt-snippet {
|
||
font-size: 7px !important; font-family: monospace !important;
|
||
background: #f8fafc !important; border: 1px solid #e2e8f0 !important;
|
||
border-radius: 3px !important; padding: 1mm !important;
|
||
max-height: 20mm !important; overflow: hidden !important;
|
||
margin-top: 1mm !important; line-height: 1.4 !important;
|
||
}
|
||
.pnt-snippet .line-num { color: #94a3b8 !important; min-width: 18px !important;
|
||
font-size: 7px !important; }
|
||
.pnt-snippet .line-code { color: #334155 !important; font-size: 7px !important; }
|
||
.pnt-snippet .code-line.current .line-code { color: #b45309 !important; }
|
||
|
||
/* Regular code-snippet in sidebar — keep styled if ever shown */
|
||
.code-snippet {
|
||
background: #f8fafc !important; border: 1px solid #e2e8f0 !important;
|
||
font-size: 8px !important; overflow: hidden !important;
|
||
}
|
||
.line-num { color: #94a3b8 !important; }
|
||
.line-code { color: #334155 !important; }
|
||
.code-line.current .line-code { color: #b45309 !important; }
|
||
|
||
/* Suppress interactive-only chrome */
|
||
.legend { display: none !important; }
|
||
.hint { display: none !important; }
|
||
.sev-badge, .tainted-badge {
|
||
-webkit-print-color-adjust: exact; print-color-adjust: exact;
|
||
}
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════
|
||
LIGHT THEME (body.light overrides every dark colour)
|
||
══════════════════════════════════════════════════════════ */
|
||
body.light { background: #f0f4f8; color: #0f172a; }
|
||
|
||
body.light .topbar { background: #ffffff; border-color: #e2e8f0; }
|
||
body.light .topbar-title { color: #0f172a; }
|
||
body.light .topbar-repo { color: #64748b; }
|
||
body.light .topbar-divider { background: #e2e8f0; }
|
||
|
||
body.light .stat-chip { background: #f1f5f9; border-color: #e2e8f0; color: #475569; }
|
||
body.light .stat-chip .val { color: #0f172a; }
|
||
body.light .stat-chip.danger .val { color: #dc2626; }
|
||
body.light .stat-chip.warn .val { color: #b45309; }
|
||
body.light .stat-chip.ok .val { color: #16a34a; }
|
||
|
||
body.light .act-btn { background: #f1f5f9; border-color: #e2e8f0; color: #475569; }
|
||
body.light .act-btn:hover { background: #e2e8f0; color: #0f172a; border-color: #94a3b8; }
|
||
body.light .act-btn.primary { background: #2563eb; border-color: #3b82f6; color: #fff; }
|
||
body.light .act-btn.primary:hover { background: #1d4ed8; }
|
||
|
||
body.light #flow-search { background: #f1f5f9; border-color: #e2e8f0; color: #0f172a; }
|
||
body.light #flow-search:focus { border-color: #3b82f6; background: #fff; }
|
||
body.light #flow-search::placeholder { color: #94a3b8; }
|
||
|
||
body.light .flow-nav { background: #e8edf5; border-color: #d1dae8; }
|
||
body.light .flow-pill { background: #ffffff; border-color: #d1dae8; color: #64748b; }
|
||
body.light .flow-pill:hover { background: #f8fafc; border-color: #94a3b8; color: #334155; }
|
||
body.light .flow-pill.active { background: #f0f4f8; border-color: #94a3b8; color: #0f172a;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
||
|
||
body.light .main { /* flex container, inherits */ }
|
||
body.light .content { /* flex container, inherits */ }
|
||
|
||
body.light .sidebar { background: #ffffff; border-color: #e2e8f0; }
|
||
body.light .sidebar h3 { color: #64748b; }
|
||
body.light .detail-label { color: #94a3b8; }
|
||
body.light .detail-value { color: #0f172a; }
|
||
|
||
body.light .zoom-bar { background: #ffffff; border-color: #e2e8f0; }
|
||
body.light .zoom-btn { background: #f1f5f9; border-color: #e2e8f0; color: #475569; }
|
||
body.light .zoom-btn:hover { background: #e2e8f0; color: #0f172a; }
|
||
body.light .zoom-label { color: #94a3b8; }
|
||
body.light .zoom-hint { color: #cbd5e1; }
|
||
|
||
body.light .svg-wrap { background: #f8fafc; border-color: #e2e8f0;
|
||
scrollbar-color: #cbd5e1 transparent; }
|
||
body.light .svg-wrap::-webkit-scrollbar-thumb { background: #cbd5e1; }
|
||
|
||
/* ── SVG light-mode overrides ──────────────────────────────────────────────
|
||
Node shapes use fill-opacity:0.22 — in dark mode the shapes are a faint
|
||
tint on a dark canvas, in light mode a faint tint on a white canvas.
|
||
Text that was white/light on dark becomes invisible on the near-white node.
|
||
ALL SVG text colours must therefore be flipped in light mode.
|
||
───────────────────────────────────────────────────────────────────────── */
|
||
|
||
/* Swim-lane bands → soft tinted backgrounds */
|
||
body.light .lane-sink { fill: #fee2e2; }
|
||
body.light .lane-entry { fill: #dcfce7; }
|
||
body.light .lane-input { fill: #fef3c7; }
|
||
body.light .lane-default { fill: #f1f5f9; }
|
||
/* Swim-lane label text → darker so readable on light bands */
|
||
body.light .lane-lbl-sink { fill: #b91c1c; }
|
||
body.light .lane-lbl-entry { fill: #15803d; }
|
||
body.light .lane-lbl-input { fill: #b45309; }
|
||
|
||
/* Overview module boxes → light card style */
|
||
body.light .module-box-body { fill: #ffffff; stroke: #e2e8f0; }
|
||
body.light .module-box-header { fill: #f1f5f9; }
|
||
/* Module text → dark so readable on now-light box */
|
||
body.light .module-header { fill: #94a3b8; }
|
||
body.light .module-name { fill: #1e293b; }
|
||
body.light .module-file { fill: #64748b; }
|
||
|
||
/* EP text → dark (box has fill-opacity:0.4 → light blended bg in light mode) */
|
||
body.light .ep-route { fill: #1e293b; }
|
||
body.light .ep-method { fill: #1e293b; }
|
||
|
||
/* Flow-view node labels: near-white text → dark on light canvas */
|
||
body.light .node-label { fill: #1e293b; }
|
||
/* Badge text: white → dark (badge pill is 60% opacity coloured bg — dark text
|
||
is readable on any hue at that opacity level) */
|
||
body.light .badge-lbl { fill: #1e293b; }
|
||
/* Subtitles: light accent colours → darker saturated equivalents */
|
||
body.light .node-sub-sink { fill: #dc2626; }
|
||
body.light .node-sub-src { fill: #b45309; }
|
||
body.light .node-sub-env { fill: #7c3aed; }
|
||
|
||
body.light h2 { color: #64748b; }
|
||
body.light .flow-meta { color: #94a3b8; }
|
||
body.light .stat-card { background: #ffffff; border-color: #e2e8f0; }
|
||
body.light .stat-card .label { color: #64748b; }
|
||
|
||
body.light .legend { border-color: #e2e8f0; }
|
||
body.light .legend-item { color: #64748b; }
|
||
body.light .hint { color: #94a3b8; }
|
||
|
||
body.light .code-snippet { background: #f8fafc; border-color: #e2e8f0; }
|
||
body.light .line-num { color: #94a3b8; }
|
||
body.light .line-code { color: #475569; }
|
||
body.light .code-line.current { background: #fffbeb; }
|
||
body.light .code-line.current .line-code { color: #b45309; }
|
||
|
||
/* Theme toggle button in topbar */
|
||
.theme-btn {
|
||
width: 28px; height: 28px; border-radius: 6px;
|
||
border: 1px solid #2d3a50; background: #1e293b;
|
||
color: #94a3b8; cursor: pointer; font-size: 14px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
transition: all .14s; flex-shrink: 0;
|
||
}
|
||
.theme-btn:hover { background: #2d3a50; color: #e2e8f0; }
|
||
body.light .theme-btn { background: #f1f5f9; border-color: #e2e8f0; color: #475569; }
|
||
body.light .theme-btn:hover { background: #e2e8f0; color: #0f172a; }
|
||
"""
|
||
|
||
|
||
# ── Embedded JavaScript ───────────────────────────────────────────────────────
|
||
|
||
EMBEDDED_JS = r"""
|
||
/* ─────────────────────────────────────────────────────────────────────────────
|
||
codeflow interactive viewer
|
||
──────────────────────────────────────────────────────────────────────────── */
|
||
|
||
/* ── 1. Viewport state (scale + pan per SVG) ────────────────────────────── */
|
||
var _vp = {};
|
||
function vp(id) {
|
||
if (!_vp[id]) _vp[id] = {s:1, drag:false, ox:0, oy:0, stx:0, sty:0};
|
||
return _vp[id];
|
||
}
|
||
|
||
/* Natural (unscaled) SVG dimensions from viewBox or width/height attrs */
|
||
function _svgNatural(svg) {
|
||
var vb = svg.viewBox && svg.viewBox.baseVal;
|
||
return {
|
||
w: vb && vb.width ? vb.width : parseFloat(svg.getAttribute('width') || '800'),
|
||
h: vb && vb.height ? vb.height : parseFloat(svg.getAttribute('height') || '600')
|
||
};
|
||
}
|
||
|
||
/*
|
||
* applyVp: apply zoom via CSS scale on the <svg> element, and size the
|
||
* .svg-canvas wrapper so the scroll range correctly reflects scaled content.
|
||
* Pan is handled entirely by scrollLeft/scrollTop on .svg-wrap.
|
||
*/
|
||
function applyVp(id) {
|
||
var v = vp(id), svg = document.getElementById(id);
|
||
if (!svg) return;
|
||
var nat = _svgNatural(svg);
|
||
/* Scale the SVG element itself (origin top-left) */
|
||
svg.style.transform = 'scale('+v.s+')';
|
||
svg.style.transformOrigin = '0 0';
|
||
/* Resize .svg-canvas so the scrollbar range equals the zoomed content */
|
||
var canvas = svg.parentElement;
|
||
if (canvas && canvas.classList.contains('svg-canvas')) {
|
||
canvas.style.width = Math.ceil(nat.w * v.s) + 'px';
|
||
canvas.style.height = Math.ceil(nat.h * v.s) + 'px';
|
||
}
|
||
var lbl = document.getElementById(id+'-zoom-label');
|
||
if (lbl) lbl.textContent = Math.round(v.s*100)+'%';
|
||
}
|
||
|
||
/* ── 2. Zoom controls ───────────────────────────────────────────────────── */
|
||
window.zoomIn = function(id){ var v=vp(id); v.s=Math.min(10,v.s+0.2); applyVp(id); };
|
||
window.zoomOut = function(id){ var v=vp(id); v.s=Math.max(0.05,v.s-0.2); applyVp(id); };
|
||
window.zoomReset = function(id){
|
||
var v=vp(id); v.s=1; applyVp(id);
|
||
var svg = document.getElementById(id);
|
||
var wrap = svg && svg.closest('.svg-wrap');
|
||
if (wrap) { wrap.scrollLeft = 0; wrap.scrollTop = 0; }
|
||
};
|
||
window.fitView = function(id){
|
||
var svg = document.getElementById(id);
|
||
var wrap = svg && svg.closest('.svg-wrap');
|
||
if (!svg || !wrap) return;
|
||
var nat = _svgNatural(svg);
|
||
if (!nat.w || !nat.h) return;
|
||
var ww = wrap.clientWidth, wh = wrap.clientHeight;
|
||
if (ww <= 0 || wh <= 0) return; /* pane not yet laid out — bail */
|
||
var sc = Math.min(ww / nat.w, wh / nat.h, 1.5);
|
||
vp(id).s = sc;
|
||
applyVp(id);
|
||
/* Centre by scrolling: if scaled content < wrap, scroll is 0 (clamped by browser) */
|
||
wrap.scrollLeft = Math.round((nat.w * sc - ww) / 2);
|
||
wrap.scrollTop = Math.round((nat.h * sc - wh) / 2);
|
||
};
|
||
|
||
/* ── 3. Mouse wheel zoom (anchor on cursor position) ────────────────────── */
|
||
window.handleWheel = function(ev) {
|
||
ev.preventDefault();
|
||
var wrap = ev.currentTarget;
|
||
var svg = wrap.querySelector('svg[id]');
|
||
if (!svg) return;
|
||
var id = svg.id, v = vp(id);
|
||
var rect = wrap.getBoundingClientRect();
|
||
/* cursor in scroll-space (accounts for current scroll position) */
|
||
var mx = ev.clientX - rect.left + wrap.scrollLeft;
|
||
var my = ev.clientY - rect.top + wrap.scrollTop;
|
||
/* cursor in natural SVG coords (undo current scale) */
|
||
var svgX = mx / v.s, svgY = my / v.s;
|
||
var step = (ev.ctrlKey || ev.metaKey) ? 0.20 : 0.10;
|
||
v.s = Math.max(0.05, Math.min(10, v.s + (ev.deltaY > 0 ? -step : step)));
|
||
applyVp(id);
|
||
/* scroll so the same SVG point stays under the cursor */
|
||
wrap.scrollLeft = Math.round(svgX * v.s - (ev.clientX - rect.left));
|
||
wrap.scrollTop = Math.round(svgY * v.s - (ev.clientY - rect.top));
|
||
};
|
||
|
||
/* ── 4. Node position + element cache ───────────────────────────────────── */
|
||
/*
|
||
* nodePos : nid → {x,y,w,h,dx,dy} layout coords + accumulated drag delta
|
||
* _nodeEls : nid → SVGElement direct ref — avoids CSS selector issues
|
||
* with IDs containing :: [ ] " etc.
|
||
*/
|
||
var nodePos = {};
|
||
var _nodeEls = {};
|
||
|
||
function initNodePositions() {
|
||
document.querySelectorAll('.node[data-x]').forEach(function(g) {
|
||
var nid = g.getAttribute('data-node-id');
|
||
if (!nid) return;
|
||
nodePos[nid] = {
|
||
x: parseFloat(g.getAttribute('data-x') || 0),
|
||
y: parseFloat(g.getAttribute('data-y') || 0),
|
||
w: parseFloat(g.getAttribute('data-w') || 180),
|
||
h: parseFloat(g.getAttribute('data-h') || 56),
|
||
dx: 0, dy: 0
|
||
};
|
||
_nodeEls[nid] = g;
|
||
});
|
||
}
|
||
|
||
/* ── 5. Active node-drag state ──────────────────────────────────────────── */
|
||
var _nd = null;
|
||
|
||
/* ── 6. Edge routing — simple cubic bezier (identical to Python _edge_path) */
|
||
function f1(n) { return Math.round(n*10)/10; }
|
||
|
||
function edgePath(p1, p2) {
|
||
var cy1 = p1.y + p1.h/2, cy2 = p2.y + p2.h/2;
|
||
|
||
/* Same layer → side-exit S-curve */
|
||
if (Math.abs(cy1 - cy2) < Math.max(p1.h, p2.h)) {
|
||
var x1=p1.x+p1.w, y1=cy1, x2=p2.x, y2=cy2, mx=(x1+x2)/2;
|
||
return 'M'+f1(x1)+','+f1(y1)+
|
||
' C'+f1(mx)+','+f1(y1)+' '+f1(mx)+','+f1(y2)+' '+f1(x2)+','+f1(y2);
|
||
}
|
||
|
||
/* Cross-layer → cubic bezier bottom-centre to top-centre */
|
||
var x1=p1.x+p1.w/2, y1=p1.y+p1.h, x2=p2.x+p2.w/2, y2=p2.y;
|
||
var dy=Math.abs(y2-y1)*0.5;
|
||
return 'M'+f1(x1)+','+f1(y1)+
|
||
' C'+f1(x1)+','+f1(y1+dy)+' '+f1(x2)+','+f1(y2-dy)+' '+f1(x2)+','+f1(y2);
|
||
}
|
||
|
||
/* ── 7. Recompute edges for a moved node ────────────────────────────────── */
|
||
function updateEdgesForNode(nid) {
|
||
/* Scope search to the node's own SVG to avoid cross-tab collisions */
|
||
var g = _nodeEls[nid];
|
||
var svg = g && g.closest('svg');
|
||
if (!svg) return;
|
||
|
||
svg.querySelectorAll('.flow-edge').forEach(function(path) {
|
||
var src = path.getAttribute('data-src');
|
||
var tgt = path.getAttribute('data-tgt');
|
||
if (src !== nid && tgt !== nid) return;
|
||
var ps = nodePos[src], pt = nodePos[tgt];
|
||
if (!ps || !pt) return;
|
||
path.setAttribute('d', edgePath(
|
||
{x: ps.x+ps.dx, y: ps.y+ps.dy, w: ps.w, h: ps.h},
|
||
{x: pt.x+pt.dx, y: pt.y+pt.dy, w: pt.w, h: pt.h}
|
||
));
|
||
});
|
||
}
|
||
|
||
/* Global handlers — live as long as a node drag is in progress */
|
||
function _onGlobalMove(ev) {
|
||
if (!_nd) { document.removeEventListener('mousemove', _onGlobalMove); return; }
|
||
var nd = _nd;
|
||
var scale = vp(nd.svgId).s || 1;
|
||
var ddx = (ev.clientX - nd.startX) / scale;
|
||
var ddy = (ev.clientY - nd.startY) / scale;
|
||
if (!nd.moved && (Math.abs(ddx) > 3 || Math.abs(ddy) > 3)) nd.moved = true;
|
||
if (!nd.moved) return;
|
||
|
||
var np = nodePos[nd.nid];
|
||
if (!np) return;
|
||
np.dx = nd.startDx + ddx;
|
||
np.dy = nd.startDy + ddy;
|
||
|
||
/* Shapes inside <g> are at absolute SVG coords; translate the group by the
|
||
accumulated delta — this correctly layers on top of the layout position. */
|
||
var g = _nodeEls[nd.nid];
|
||
if (g) g.setAttribute('transform', 'translate('+f1(np.dx)+','+f1(np.dy)+')');
|
||
updateEdgesForNode(nd.nid);
|
||
}
|
||
|
||
function _onGlobalUp() {
|
||
document.removeEventListener('mousemove', _onGlobalMove);
|
||
if (_nd) {
|
||
if (!_nd.moved) {
|
||
showNodeDetail(_nd.nid);
|
||
highlightTaintChain(_nd.nid);
|
||
}
|
||
_nd = null;
|
||
}
|
||
}
|
||
|
||
/* ── 8. Pan mouse handlers (scroll-based) ───────────────────────────────── */
|
||
window.handleMouseDown = function(ev) {
|
||
if (ev.button !== 0) return;
|
||
ev.preventDefault();
|
||
|
||
var ng = ev.target.closest('.node[data-node-id]');
|
||
if (ng) {
|
||
var nid = ng.getAttribute('data-node-id');
|
||
if (nodePos[nid]) {
|
||
var svgEl = ng.closest('svg');
|
||
_nd = {
|
||
nid: nid, svgId: svgEl ? svgEl.id : '',
|
||
startX: ev.clientX, startY: ev.clientY,
|
||
startDx: nodePos[nid].dx,
|
||
startDy: nodePos[nid].dy,
|
||
moved: false
|
||
};
|
||
document.addEventListener('mousemove', _onGlobalMove);
|
||
document.addEventListener('mouseup', _onGlobalUp, {once: true});
|
||
return;
|
||
}
|
||
}
|
||
|
||
/* Background → clear highlight + start scroll-pan */
|
||
clearHighlight();
|
||
var wrap = ev.currentTarget;
|
||
var svg = wrap.querySelector('svg[id]');
|
||
if (!svg) return;
|
||
var v = vp(svg.id);
|
||
v.drag = true;
|
||
v.ox = ev.clientX; v.oy = ev.clientY;
|
||
v.stx = wrap.scrollLeft; v.sty = wrap.scrollTop;
|
||
wrap.style.cursor = 'grabbing';
|
||
};
|
||
|
||
window.handleMouseMove = function(ev) {
|
||
if (_nd) return;
|
||
var wrap = ev.currentTarget;
|
||
var svg = wrap.querySelector('svg[id]');
|
||
if (!svg) return;
|
||
var v = vp(svg.id);
|
||
if (!v.drag) return;
|
||
/* Scroll the wrapper — drag right = content moves right = scrollLeft decreases */
|
||
wrap.scrollLeft = v.stx - (ev.clientX - v.ox);
|
||
wrap.scrollTop = v.sty - (ev.clientY - v.oy);
|
||
};
|
||
|
||
window.handleMouseUp = function(ev) {
|
||
if (_nd) return;
|
||
var wrap = ev.currentTarget;
|
||
var svg = wrap.querySelector('svg[id]');
|
||
if (svg) vp(svg.id).drag = false;
|
||
wrap.style.cursor = 'grab';
|
||
};
|
||
|
||
/* ── 9. Taint-path highlighting ─────────────────────────────────────────── */
|
||
var _highlightActive = false;
|
||
|
||
function highlightTaintChain(nid) {
|
||
var data = nodeData[nid];
|
||
if (!data) return;
|
||
|
||
/* Use cached element reference — no CSS selector needed */
|
||
var g = _nodeEls[nid];
|
||
var svg = g && g.closest('svg');
|
||
if (!svg) return;
|
||
|
||
var flowId = svg.getAttribute('data-flow-id') || '';
|
||
var fEdges = flowEdges[flowId] || [];
|
||
|
||
/* Build bi-directional adjacency for CALLS / DATA_FLOW / TAINTED_FLOW */
|
||
var fwd = {}, bwd = {};
|
||
fEdges.forEach(function(e) {
|
||
if (!fwd[e.src]) fwd[e.src] = [];
|
||
fwd[e.src].push(e.tgt);
|
||
if (!bwd[e.tgt]) bwd[e.tgt] = [];
|
||
bwd[e.tgt].push(e.src);
|
||
});
|
||
|
||
/* BFS outward from clicked node, only through tainted nodes */
|
||
var visited = {}; visited[nid] = true;
|
||
var queue = [nid];
|
||
while (queue.length) {
|
||
var cur = queue.shift();
|
||
var neighbours = (fwd[cur] || []).concat(bwd[cur] || []);
|
||
neighbours.forEach(function(n) {
|
||
if (visited[n]) return;
|
||
var nd2 = nodeData[n];
|
||
if (nd2 && (nd2.tainted || n === nid)) {
|
||
visited[n] = true;
|
||
queue.push(n);
|
||
}
|
||
});
|
||
}
|
||
/* Always include direct neighbours even if not tainted, so the path is visible */
|
||
(fwd[nid] || []).forEach(function(n) { visited[n] = true; });
|
||
(bwd[nid] || []).forEach(function(n) { visited[n] = true; });
|
||
|
||
/* Apply classes */
|
||
_highlightActive = true;
|
||
svg.classList.add('taint-active');
|
||
|
||
svg.querySelectorAll('.node').forEach(function(el) {
|
||
el.classList.toggle('taint-on', !!visited[el.getAttribute('data-node-id')]);
|
||
});
|
||
svg.querySelectorAll('.flow-edge').forEach(function(el) {
|
||
var on = !!(visited[el.getAttribute('data-src')] && visited[el.getAttribute('data-tgt')]);
|
||
el.classList.toggle('taint-on', on);
|
||
});
|
||
}
|
||
|
||
function clearHighlight() {
|
||
if (!_highlightActive) return;
|
||
document.querySelectorAll('.taint-active').forEach(function(svg) {
|
||
svg.classList.remove('taint-active');
|
||
svg.querySelectorAll('.taint-on').forEach(function(el) {
|
||
el.classList.remove('taint-on');
|
||
});
|
||
});
|
||
_highlightActive = false;
|
||
}
|
||
|
||
/* ── 10. Tab switching & flow search ────────────────────────────────────── */
|
||
function showTab(tabId) {
|
||
document.querySelectorAll('.flow-pill').forEach(function(b) {
|
||
b.classList.toggle('active', b.getAttribute('data-tab') === tabId);
|
||
});
|
||
document.querySelectorAll('.content-pane').forEach(function(p) {
|
||
p.classList.toggle('active', p.id === tabId);
|
||
});
|
||
/* Fit the newly-revealed SVG — double-RAF waits for the browser to reflow
|
||
the pane from display:none → display:flex before measuring clientHeight */
|
||
var pane = document.getElementById(tabId);
|
||
if (pane) {
|
||
var svg = pane.querySelector('svg[id]');
|
||
if (svg) {
|
||
var svgId = svg.id;
|
||
requestAnimationFrame(function() {
|
||
requestAnimationFrame(function() { fitView(svgId); });
|
||
});
|
||
}
|
||
}
|
||
clearHighlight();
|
||
}
|
||
|
||
window.filterFlows = function(q) {
|
||
q = q.trim().toLowerCase();
|
||
document.querySelectorAll('.flow-pill').forEach(function(b) {
|
||
var match = !q || b.textContent.toLowerCase().indexOf(q) !== -1;
|
||
b.style.display = match ? '' : 'none';
|
||
});
|
||
};
|
||
|
||
/* ── 10b. Export ─────────────────────────────────────────────────────────── */
|
||
window.exportPDF = function() {
|
||
window.print();
|
||
};
|
||
|
||
/* ── 10c. Light / dark theme toggle ─────────────────────────────────────── */
|
||
window.toggleTheme = function() {
|
||
var isLight = document.body.classList.toggle('light');
|
||
try { localStorage.setItem('cf-theme', isLight ? 'light' : 'dark'); } catch(e){}
|
||
var btn = document.getElementById('theme-toggle-btn');
|
||
if (btn) btn.textContent = isLight ? '🌙' : '☀️';
|
||
};
|
||
|
||
window.exportSVG = function() {
|
||
var pane = document.querySelector('.content-pane.active');
|
||
if (!pane) return;
|
||
var svg = pane.querySelector('svg');
|
||
if (!svg) return;
|
||
var data = new XMLSerializer().serializeToString(svg);
|
||
var blob = new Blob([data], {type: 'image/svg+xml'});
|
||
var url = URL.createObjectURL(blob);
|
||
var a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = (pane.id || 'codeflow') + '.svg';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
setTimeout(function(){ URL.revokeObjectURL(url); a.remove(); }, 600);
|
||
};
|
||
|
||
/* ── 11. Node detail panel ──────────────────────────────────────────────── */
|
||
function esc(s) {
|
||
return String(s)
|
||
.replace(/&/g,'&').replace(/</g,'<')
|
||
.replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
function detail(label, value) {
|
||
return '<div class="detail-item">'
|
||
+ '<div class="detail-label">'+esc(label)+'</div>'
|
||
+ '<div class="detail-value">'+esc(String(value))+'</div>'
|
||
+ '</div>';
|
||
}
|
||
|
||
function showNodeDetail(nid) {
|
||
var data = nodeData[nid];
|
||
if (!data) return;
|
||
var sev = (data.metadata && data.metadata.severity) || '';
|
||
var sevColor = {CRITICAL:'#e53e3e',HIGH:'#dd6b20',MEDIUM:'#d69e2e',LOW:'#68d391',INFO:'#718096'}[sev] || '#718096';
|
||
|
||
var out = '<h3 style="margin-bottom:10px">Node Details</h3>';
|
||
|
||
/* severity + taint badges */
|
||
if (sev && sev !== 'INFO') {
|
||
out += '<div class="detail-item"><span class="sev-badge" style="background:'+sevColor+'">'+esc(sev)+'</span></div>';
|
||
}
|
||
if (data.tainted) {
|
||
out += '<div class="detail-item"><span class="tainted-badge">TAINTED</span></div>';
|
||
}
|
||
|
||
out += detail('Type', data.type);
|
||
out += detail('Label', data.label);
|
||
|
||
/* file + line */
|
||
if (data.file) {
|
||
var fname = data.file.replace(/\\/g,'/').split('/').pop();
|
||
out += detail('File', fname + (data.line ? ':'+data.line : ''));
|
||
}
|
||
|
||
/* metadata (skip severity since we show it as badge) */
|
||
var meta = data.metadata || {};
|
||
Object.keys(meta).forEach(function(k) {
|
||
if (k === 'severity') return;
|
||
out += detail(k, meta[k]);
|
||
});
|
||
|
||
/* code snippet */
|
||
var snip = data.snippet;
|
||
if (snip && snip.length) {
|
||
out += '<div class="detail-item"><div class="detail-label">Source</div>';
|
||
out += '<div class="code-snippet">';
|
||
snip.forEach(function(ln) {
|
||
var cur = (ln.n === data.line) ? ' current' : '';
|
||
out += '<div class="code-line'+cur+'">'
|
||
+ '<span class="line-num">'+ln.n+'</span>'
|
||
+ '<span class="line-code">'+esc(ln.code)+'</span>'
|
||
+ '</div>';
|
||
});
|
||
out += '</div></div>';
|
||
}
|
||
|
||
if (data.tainted) {
|
||
out += '<div class="hint" style="margin-top:8px">Click node to highlight taint path. Click background to clear.</div>';
|
||
}
|
||
|
||
document.getElementById('sidebar-node').innerHTML = out;
|
||
}
|
||
|
||
/* ── 12. Init ───────────────────────────────────────────────────────────── */
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
/* Restore saved theme */
|
||
try {
|
||
if (localStorage.getItem('cf-theme') === 'light') {
|
||
document.body.classList.add('light');
|
||
var btn = document.getElementById('theme-toggle-btn');
|
||
if (btn) btn.textContent = '🌙';
|
||
}
|
||
} catch(e){}
|
||
|
||
/* Activate first pill */
|
||
var firstPill = document.querySelector('.flow-pill');
|
||
if (firstPill) showTab(firstPill.getAttribute('data-tab'));
|
||
|
||
/* Pill click events */
|
||
document.querySelectorAll('.flow-pill').forEach(function(btn) {
|
||
btn.addEventListener('click', function() {
|
||
showTab(this.getAttribute('data-tab'));
|
||
});
|
||
});
|
||
|
||
/* Search box */
|
||
var searchBox = document.getElementById('flow-search');
|
||
if (searchBox) searchBox.addEventListener('input', function() { filterFlows(this.value); });
|
||
|
||
/* Initialise node positions from SVG data attributes */
|
||
initNodePositions();
|
||
|
||
/* Fit the initially-visible SVG — double-RAF ensures layout is complete */
|
||
document.querySelectorAll('.content-pane.active svg[id]').forEach(function(svg) {
|
||
var svgId = svg.id;
|
||
requestAnimationFrame(function() {
|
||
requestAnimationFrame(function() { fitView(svgId); });
|
||
});
|
||
});
|
||
});
|
||
|
||
/* ── 13. Print: fit full graph into page without clipping ────────────────────
|
||
Architecture recap:
|
||
• Pan/zoom → svg.style.transform 'translate(tx,ty) scale(s)' (applyVp)
|
||
• Node drag → node <g>.transform 'translate(dx,dy)' (drag)
|
||
• Lane bands → x=-9999 width=99999 (must be excluded from bbox)
|
||
|
||
Fix: clear the CSS pan/zoom, recompute viewBox from node data attrs + their
|
||
individual drag offsets, then hand width/height control to CSS. */
|
||
window.addEventListener('beforeprint', function() {
|
||
document.querySelectorAll('.svg-wrap svg[id]').forEach(function(svg) {
|
||
/* Save state */
|
||
svg.dataset.printW = svg.getAttribute('width') || '';
|
||
svg.dataset.printH = svg.getAttribute('height') || '';
|
||
svg.dataset.printVB = svg.getAttribute('viewBox') || '';
|
||
svg.dataset.printTr = svg.style.transform || '';
|
||
|
||
/* 1. Clear CSS scale so SVG renders at natural viewBox origin */
|
||
svg.style.transform = '';
|
||
svg.style.transformOrigin = '';
|
||
|
||
/* 2. Also reset the canvas size so it doesn't interfere with print layout */
|
||
var canvas = svg.parentElement;
|
||
if (canvas && canvas.classList.contains('svg-canvas')) {
|
||
svg.dataset.printCanvasW = canvas.style.width || '';
|
||
svg.dataset.printCanvasH = canvas.style.height || '';
|
||
canvas.style.width = '';
|
||
canvas.style.height = '';
|
||
}
|
||
|
||
/* 3. Compute tight viewBox from node positions + drag offsets.
|
||
Lane bands have x=-9999 — skip them by selecting only g.node. */
|
||
var nodes = svg.querySelectorAll('g.node[data-x]');
|
||
var pad = 30;
|
||
if (nodes.length > 0) {
|
||
var minX = Infinity, minY = Infinity;
|
||
var maxX = -Infinity, maxY = -Infinity;
|
||
nodes.forEach(function(g) {
|
||
var nx = parseFloat(g.getAttribute('data-x') || '0');
|
||
var ny = parseFloat(g.getAttribute('data-y') || '0');
|
||
var nw = parseFloat(g.getAttribute('data-w') || '0');
|
||
var nh = parseFloat(g.getAttribute('data-h') || '0');
|
||
var dx = 0, dy = 0;
|
||
var m = (g.getAttribute('transform') || '')
|
||
.match(/translate\(\s*([+-]?[\d.]+)\s*,\s*([+-]?[\d.]+)\s*\)/);
|
||
if (m) { dx = parseFloat(m[1]); dy = parseFloat(m[2]); }
|
||
minX = Math.min(minX, nx + dx);
|
||
minY = Math.min(minY, ny + dy);
|
||
maxX = Math.max(maxX, nx + dx + nw);
|
||
maxY = Math.max(maxY, ny + dy + nh);
|
||
});
|
||
if (maxX > minX && maxY > minY) {
|
||
svg.setAttribute('viewBox',
|
||
(minX-pad)+' '+(minY-pad)+' '+
|
||
(maxX-minX+pad*2)+' '+(maxY-minY+pad*2));
|
||
}
|
||
}
|
||
|
||
/* 4. Let CSS (@media print) control the rendered size */
|
||
svg.setAttribute('width', '100%');
|
||
svg.setAttribute('height', '100%');
|
||
});
|
||
});
|
||
|
||
window.addEventListener('afterprint', function() {
|
||
document.querySelectorAll('.svg-wrap svg[id]').forEach(function(svg) {
|
||
/* Restore CSS scale */
|
||
svg.style.transform = svg.dataset.printTr || '';
|
||
svg.style.transformOrigin = svg.dataset.printTr ? '0 0' : '';
|
||
|
||
/* Restore canvas size */
|
||
var canvas = svg.parentElement;
|
||
if (canvas && canvas.classList.contains('svg-canvas')) {
|
||
canvas.style.width = svg.dataset.printCanvasW || '';
|
||
canvas.style.height = svg.dataset.printCanvasH || '';
|
||
delete svg.dataset.printCanvasW;
|
||
delete svg.dataset.printCanvasH;
|
||
}
|
||
|
||
/* Restore SVG attributes */
|
||
if (svg.dataset.printVB) svg.setAttribute('viewBox', svg.dataset.printVB);
|
||
if (svg.dataset.printW) svg.setAttribute('width', svg.dataset.printW);
|
||
if (svg.dataset.printH) svg.setAttribute('height', svg.dataset.printH);
|
||
|
||
delete svg.dataset.printW;
|
||
delete svg.dataset.printH;
|
||
delete svg.dataset.printVB;
|
||
delete svg.dataset.printTr;
|
||
});
|
||
});
|
||
"""
|
||
|
||
|
||
# ── Legend HTML ───────────────────────────────────────────────────────────────
|
||
|
||
_LEGEND_HTML = """
|
||
<div class="legend">
|
||
<h3>Legend — Nodes</h3>
|
||
<div class="legend-item">
|
||
<span class="swatch" style="background:#c05621;opacity:.35;border:1.5px solid #c05621"></span>
|
||
User Input (SOURCE ◇)
|
||
</div>
|
||
<div class="legend-item">
|
||
<span class="swatch" style="background:#276749;opacity:.35;border:1.5px solid #276749"></span>
|
||
Entry Point
|
||
</div>
|
||
<div class="legend-item">
|
||
<span class="swatch" style="background:#2b6cb0;opacity:.35;border:1.5px solid #2b6cb0"></span>
|
||
Function
|
||
</div>
|
||
<div class="legend-item">
|
||
<span class="swatch" style="background:#9b2c2c;opacity:.35;border:1.5px solid #9b2c2c"></span>
|
||
Dangerous Sink ⬡
|
||
</div>
|
||
<div class="legend-item">
|
||
<span class="swatch" style="background:#2c5282;opacity:.35;border:1.5px solid #2c5282"></span>
|
||
Parameter ▱
|
||
</div>
|
||
<div class="legend-item">
|
||
<span class="swatch-border"></span>
|
||
Tainted node (red border)
|
||
</div>
|
||
<div class="legend-item">
|
||
<span class="swatch" style="background:#4a5568;opacity:.35;border:1.5px dashed #4a5568"></span>
|
||
Sanitizer / Boundary
|
||
</div>
|
||
|
||
<div style="margin:8px 0 4px;font-size:10px;color:#4a5568;text-transform:uppercase;letter-spacing:.5px">Edges</div>
|
||
<div class="legend-item">
|
||
<span class="edge-line" style="background:#718096;opacity:.5"></span>
|
||
Function call
|
||
</div>
|
||
<div class="legend-item">
|
||
<span class="edge-line" style="background:#dd6b20"></span>
|
||
Data flow
|
||
</div>
|
||
<div class="legend-item">
|
||
<span class="edge-line" style="background:#e53e3e"></span>
|
||
Tainted flow
|
||
</div>
|
||
|
||
<div style="margin:8px 0 4px;font-size:10px;color:#4a5568;text-transform:uppercase;letter-spacing:.5px">Severity</div>
|
||
<div class="legend-item"><span class="sev-dot" style="background:#e53e3e"></span> CRITICAL</div>
|
||
<div class="legend-item"><span class="sev-dot" style="background:#dd6b20"></span> HIGH</div>
|
||
<div class="legend-item"><span class="sev-dot" style="background:#d69e2e"></span> MEDIUM</div>
|
||
<div class="legend-item"><span class="sev-dot" style="background:#68d391"></span> LOW</div>
|
||
|
||
<div style="margin:10px 0 4px"></div>
|
||
<div class="hint">
|
||
🖱 Scroll to zoom · Drag background to pan<br>
|
||
✋ Drag any node to reposition it<br>
|
||
🖱 Click node → details + taint highlight<br>
|
||
🖱 Click background → clear highlight<br>
|
||
⬛ Fit button → auto-scale to window
|
||
</div>
|
||
</div>"""
|
||
|
||
|
||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
def _tag_svg_id(svg: str, svg_id: str) -> str:
|
||
# Check only the opening <svg ...> tag for a *standalone* id= attribute.
|
||
# Must be preceded by whitespace to avoid matching data-*-id= attributes
|
||
# (e.g. data-flow-id in flow SVGs would false-positive the old regex).
|
||
m = re.match(r'<svg\b[^>]*>', svg, re.DOTALL)
|
||
if m and re.search(r'\sid\s*=', m.group()):
|
||
return svg
|
||
return svg.replace("<svg", f'<svg id="{svg_id}"', 1)
|
||
|
||
|
||
def _zoom_bar(svg_id: str) -> str:
|
||
return (
|
||
f'<div class="zoom-bar">'
|
||
f'<button class="zoom-btn" onclick="zoomIn(\'{svg_id}\')" title="Zoom in">+</button>'
|
||
f'<button class="zoom-btn" onclick="zoomOut(\'{svg_id}\')" title="Zoom out">−</button>'
|
||
f'<button class="zoom-btn" onclick="zoomReset(\'{svg_id}\')" title="Reset zoom (1:1)">↺</button>'
|
||
f'<button class="zoom-btn" onclick="fitView(\'{svg_id}\')" title="Fit to window" style="font-size:11px">⛶</button>'
|
||
f'<span class="zoom-label" id="{svg_id}-zoom-label">100%</span>'
|
||
f'<span class="zoom-hint">scroll = zoom · drag bg = pan · drag node = move</span>'
|
||
f'</div>'
|
||
)
|
||
|
||
|
||
def _svg_wrap(svg: str) -> str:
|
||
# .svg-wrap — overflow:auto container with scrollbars + drag-to-pan
|
||
# .svg-canvas — sized by JS to (svgW × scale) so scroll range = zoomed content
|
||
return (
|
||
'<div class="svg-wrap"'
|
||
' onwheel="handleWheel(event)"'
|
||
' onmousedown="handleMouseDown(event)"'
|
||
' onmousemove="handleMouseMove(event)"'
|
||
' onmouseup="handleMouseUp(event)"'
|
||
' onmouseleave="handleMouseUp(event)">'
|
||
'<div class="svg-canvas">'
|
||
+ svg +
|
||
'</div>'
|
||
'</div>'
|
||
)
|
||
|
||
|
||
def _get_snippet(file_path: str, line_start: int, context: int = 3) -> list[dict]:
|
||
"""Extract source lines around line_start for the sidebar code panel."""
|
||
if not file_path or not line_start:
|
||
return []
|
||
try:
|
||
p = Path(file_path)
|
||
if not p.exists() or p.stat().st_size > 500_000:
|
||
return []
|
||
lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
|
||
lo = max(0, line_start - context - 1)
|
||
hi = min(len(lines), line_start + context)
|
||
return [{"n": lo + i + 1, "code": lines[lo + i]} for i in range(hi - lo)]
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def _compute_stats(graph: CodeGraph, flows: list[Flow]) -> dict:
|
||
all_nodes = graph.nodes()
|
||
tainted_flows = sum(1 for f in flows if f.severity in ("CRITICAL", "HIGH", "MEDIUM"))
|
||
return {
|
||
"nodes": len(all_nodes),
|
||
"entries": sum(1 for n in all_nodes if (nd := graph.get_node(n)) and nd.node_type.name == "ENTRY_POINT"),
|
||
"sources": sum(1 for n in all_nodes if (nd := graph.get_node(n)) and nd.node_type.name == "SOURCE"),
|
||
"sinks": sum(1 for n in all_nodes if (nd := graph.get_node(n)) and nd.node_type.name == "SINK"),
|
||
"tainted": sum(1 for n in all_nodes if (nd := graph.get_node(n)) and nd.is_tainted),
|
||
"flows": len(flows),
|
||
"tainted_flows": tainted_flows,
|
||
"edges": graph.edge_count(),
|
||
}
|
||
|
||
|
||
def _node_data_json(graph: CodeGraph) -> str:
|
||
data = {}
|
||
for nid in graph.nodes():
|
||
node = graph.get_node(nid)
|
||
if node:
|
||
data[node.id] = {
|
||
"type": node.node_type.value,
|
||
"label": node.label,
|
||
"file": node.file_path,
|
||
"line": node.line_start,
|
||
"tainted": node.is_tainted,
|
||
"snippet": _get_snippet(node.file_path, node.line_start),
|
||
"metadata": {
|
||
k: v for k, v in node.metadata.items()
|
||
if isinstance(v, (str, int, float, bool))
|
||
},
|
||
}
|
||
return json.dumps(data)
|
||
|
||
|
||
def _flow_edges_json(graph: CodeGraph, flows: list[Flow]) -> str:
|
||
"""Build per-flow edge list for taint-path highlighting in JS."""
|
||
_TAINT_TYPES = {EdgeType.CALLS, EdgeType.DATA_FLOW, EdgeType.TAINTED_FLOW}
|
||
result: dict[str, list[dict]] = {}
|
||
for flow in flows:
|
||
flow_set = set(flow.node_ids)
|
||
edges = []
|
||
for u, v, edge in graph.edges():
|
||
if edge.edge_type in _TAINT_TYPES and u in flow_set and v in flow_set:
|
||
edges.append({"src": u, "tgt": v, "type": edge.edge_type.value})
|
||
result[flow.id] = edges
|
||
return json.dumps(result)
|
||
|
||
|
||
# ── HTTP method colours (for pill chips) ─────────────────────────────────────
|
||
|
||
_METHOD_COLOR = {
|
||
"GET": "#276749", # green
|
||
"POST": "#1d4ed8", # blue
|
||
"PUT": "#b45309", # amber
|
||
"DELETE": "#9b1c1c", # red
|
||
"PATCH": "#5b21b6", # violet
|
||
}
|
||
|
||
|
||
# ── Print node table ─────────────────────────────────────────────────────────
|
||
|
||
# Colour map for node-type badges in the print cards
|
||
_PRINT_TYPE_COLOR = {
|
||
"ENTRY_POINT": "#2563eb",
|
||
"SOURCE": "#d97706",
|
||
"ENV_VAR": "#059669",
|
||
"SINK": "#dc2626",
|
||
"FUNCTION": "#7c3aed",
|
||
"MODULE": "#475569",
|
||
}
|
||
|
||
# Sort order: entry → inputs → functions/modules → sinks
|
||
_PRINT_TYPE_ORDER = {
|
||
"ENTRY_POINT": 0,
|
||
"SOURCE": 1,
|
||
"ENV_VAR": 2,
|
||
"FUNCTION": 3,
|
||
"MODULE": 4,
|
||
"SINK": 5,
|
||
}
|
||
|
||
|
||
def _print_node_table(graph: CodeGraph, flow: Flow) -> str:
|
||
"""
|
||
Build a hidden-on-screen, print-only div that lists every node in the flow
|
||
as a compact card (type badge, label, file:line, metadata, code snippet).
|
||
Injected into each flow pane; shown only via @media print CSS.
|
||
"""
|
||
import html as _html
|
||
|
||
sorted_nids = sorted(
|
||
flow.node_ids,
|
||
key=lambda nid: _PRINT_TYPE_ORDER.get(
|
||
graph.get_node(nid).node_type.name if graph.get_node(nid) else "", 99
|
||
),
|
||
)
|
||
|
||
cards: list[str] = []
|
||
for nid in sorted_nids:
|
||
node = graph.get_node(nid)
|
||
if not node:
|
||
continue
|
||
|
||
type_name = node.node_type.name
|
||
type_color = _PRINT_TYPE_COLOR.get(type_name, "#475569")
|
||
sev = node.metadata.get("severity", "")
|
||
sev_color = _SEV_COLOR.get(sev, "#718096")
|
||
|
||
# File + line label
|
||
fname = ""
|
||
if node.file_path:
|
||
fname = node.file_path.replace("\\", "/").split("/")[-1]
|
||
if node.line_start:
|
||
fname += f":{node.line_start}"
|
||
|
||
# ── Badges ──
|
||
type_lbl = type_name.replace("_", " ")
|
||
badges = (
|
||
f'<span class="pnt-type" style="background:{type_color}">'
|
||
f'{_html.escape(type_lbl)}</span>'
|
||
)
|
||
if node.is_tainted:
|
||
badges += '<span class="pnt-tainted">TAINTED</span>'
|
||
if sev and sev != "INFO":
|
||
badges += (
|
||
f'<span class="pnt-sev" style="background:{sev_color}">'
|
||
f'{_html.escape(sev)}</span>'
|
||
)
|
||
|
||
# ── Metadata rows ──
|
||
meta_rows = ""
|
||
for k, v in node.metadata.items():
|
||
if k == "severity" or not isinstance(v, (str, int, float, bool)):
|
||
continue
|
||
meta_rows += (
|
||
f'<div class="pnt-meta-row">'
|
||
f'<span class="pnt-meta-key">{_html.escape(str(k))}</span>'
|
||
f'<span class="pnt-meta-val">{_html.escape(str(v))}</span>'
|
||
f'</div>'
|
||
)
|
||
|
||
# ── Code snippet ──
|
||
snippet_html = ""
|
||
snip = _get_snippet(node.file_path, node.line_start, context=3)
|
||
if snip:
|
||
lines_html = ""
|
||
for ln in snip:
|
||
cur_cls = " current" if ln["n"] == node.line_start else ""
|
||
lines_html += (
|
||
f'<div class="code-line{cur_cls}">'
|
||
f'<span class="line-num">{ln["n"]}</span>'
|
||
f'<span class="line-code">{_html.escape(ln["code"])}</span>'
|
||
f'</div>'
|
||
)
|
||
snippet_html = f'<div class="pnt-snippet">{lines_html}</div>'
|
||
|
||
# ── Assemble card ──
|
||
card = (
|
||
f'<div class="pnt-card">'
|
||
f'<div class="pnt-badges">{badges}</div>'
|
||
f'<div class="pnt-label">{_html.escape(node.label)}</div>'
|
||
)
|
||
if fname:
|
||
card += f'<div class="pnt-file">{_html.escape(fname)}</div>'
|
||
if meta_rows:
|
||
card += f'<div class="pnt-meta">{meta_rows}</div>'
|
||
card += snippet_html
|
||
card += '</div>'
|
||
|
||
cards.append(card)
|
||
|
||
if not cards:
|
||
return ""
|
||
|
||
return (
|
||
'<div class="print-node-table">'
|
||
'<div class="pnt-section-title">Node Details</div>'
|
||
'<div class="pnt-grid">'
|
||
+ "".join(cards)
|
||
+ '</div></div>'
|
||
)
|
||
|
||
|
||
# ── Main builder ──────────────────────────────────────────────────────────────
|
||
|
||
def build_html(
|
||
graph: CodeGraph,
|
||
flows: list[Flow],
|
||
flow_svgs: dict[str, str],
|
||
overview_svg: str,
|
||
repo_name: str = "repository",
|
||
) -> str:
|
||
import random
|
||
|
||
stats = _compute_stats(graph, flows)
|
||
node_data_json = _node_data_json(graph)
|
||
flow_edges_js = _flow_edges_json(graph, flows)
|
||
|
||
# ── Overview pane ──
|
||
ov_svg_id = "svg-overview"
|
||
ov_svg = _tag_svg_id(overview_svg, ov_svg_id)
|
||
ov_pane = (
|
||
'<div class="content-pane active" id="overview">'
|
||
'<h2>Overview — all flows</h2>'
|
||
+ _zoom_bar(ov_svg_id)
|
||
+ _svg_wrap(ov_svg)
|
||
+ '</div>'
|
||
)
|
||
# Overview pill (no severity bar, no method chip)
|
||
pills = '<button class="flow-pill active" data-tab="overview" style="--sev-color:#475569">'
|
||
pills += '<span class="pill-name">Overview</span></button>\n'
|
||
tab_panes = ov_pane + "\n"
|
||
|
||
# ── Flow pills + panes ──
|
||
for flow in flows:
|
||
safe_id = "".join(
|
||
c for c in flow.id if c.isalnum() or c in "-_"
|
||
).strip() or f"flow_{random.randint(0, 9999)}"
|
||
tab_id = f"flow-{safe_id}"
|
||
svg_id = f"svg-{safe_id}"
|
||
|
||
svg = _tag_svg_id(flow_svgs.get(flow.id, ""), svg_id)
|
||
|
||
sev = flow.severity
|
||
sev_color = _SEV_COLOR.get(sev, "#475569")
|
||
|
||
# HTTP method from entry node metadata
|
||
http_method = ""
|
||
if flow.entry_node_id:
|
||
entry_nd = graph.get_node(flow.entry_node_id)
|
||
if entry_nd:
|
||
http_method = (
|
||
entry_nd.metadata.get("http_method")
|
||
or entry_nd.metadata.get("method")
|
||
or ""
|
||
).upper()
|
||
|
||
method_chip_html = ""
|
||
if http_method and http_method in _METHOD_COLOR:
|
||
mc = _METHOD_COLOR[http_method]
|
||
method_chip_html = (
|
||
f'<span class="method-chip" style="background:{mc}">{http_method}</span>'
|
||
)
|
||
|
||
sev_chip_html = ""
|
||
if sev not in ("INFO", ""):
|
||
sev_chip_html = (
|
||
f'<span class="pill-sev" style="--sev-color:{sev_color}">{sev}</span>'
|
||
)
|
||
|
||
pills += (
|
||
f'<button class="flow-pill" data-tab="{tab_id}"'
|
||
f' style="--sev-color:{sev_color}">'
|
||
f'{method_chip_html}'
|
||
f'<span class="pill-name">{flow.name}</span>'
|
||
f'{sev_chip_html}'
|
||
f'</button>\n'
|
||
)
|
||
|
||
# Pane meta row
|
||
meta_parts = []
|
||
if flow.source_node_ids:
|
||
meta_parts.append(f'<span>Inputs: {len(flow.source_node_ids)}</span>')
|
||
if flow.sink_node_ids:
|
||
meta_parts.append(f'<span>Sinks: {len(flow.sink_node_ids)}</span>')
|
||
if flow.tainted_node_ids:
|
||
meta_parts.append(f'<span style="color:#fc8181">Tainted: {len(flow.tainted_node_ids)}</span>')
|
||
if sev != "INFO":
|
||
meta_parts.append(
|
||
f'<span style="color:{sev_color};font-weight:600">Severity: {sev}</span>'
|
||
)
|
||
meta_html = f'<div class="flow-meta">{"".join(meta_parts)}</div>' if meta_parts else ""
|
||
|
||
pane = (
|
||
f'<div class="content-pane" id="{tab_id}">'
|
||
f'<h2>{flow.name}</h2>'
|
||
+ meta_html
|
||
+ _zoom_bar(svg_id)
|
||
+ _svg_wrap(svg)
|
||
+ _print_node_table(graph, flow)
|
||
+ '</div>'
|
||
)
|
||
tab_panes += pane + "\n"
|
||
|
||
# ── Top-bar stat chips ──
|
||
def _chip(val: str, label: str, cls: str = "") -> str:
|
||
return (
|
||
f'<div class="stat-chip {cls}">'
|
||
f'<span class="val">{val}</span> {label}'
|
||
f'</div>'
|
||
)
|
||
|
||
tainted_cls = "danger" if stats["tainted_flows"] > 0 else "ok"
|
||
chips_html = (
|
||
'<div class="stat-chips">'
|
||
+ _chip(str(stats["flows"]), "flows")
|
||
+ _chip(str(stats["tainted_flows"]), "findings", tainted_cls)
|
||
+ _chip(str(stats["entries"]), "entries", "ok")
|
||
+ _chip(str(stats["sources"]), "sources", "warn")
|
||
+ _chip(str(stats["sinks"]), "sinks", "danger")
|
||
+ _chip(str(stats["nodes"]), "nodes")
|
||
+ '</div>'
|
||
)
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>codeflow — {repo_name}</title>
|
||
<style>{EMBEDDED_CSS}</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ══ TOP BAR ══════════════════════════════════════════════════════════ -->
|
||
<div class="topbar">
|
||
<div class="topbar-brand">
|
||
<div class="topbar-logo">CF</div>
|
||
<div>
|
||
<div class="topbar-title">codeflow</div>
|
||
</div>
|
||
</div>
|
||
<div class="topbar-divider"></div>
|
||
<span class="topbar-repo">{repo_name}</span>
|
||
<div class="topbar-divider"></div>
|
||
{chips_html}
|
||
<div class="topbar-actions">
|
||
<input type="search" id="flow-search" placeholder="🔍 filter…" autocomplete="off">
|
||
<button class="act-btn" onclick="exportSVG()" title="Export active flow as SVG">↓ SVG</button>
|
||
<button class="act-btn primary" onclick="exportPDF()" title="Print / Save as PDF">🖨 PDF</button>
|
||
<button class="theme-btn" id="theme-toggle-btn" onclick="toggleTheme()" title="Toggle light / dark theme">☀️</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══ FLOW NAV ═════════════════════════════════════════════════════════ -->
|
||
<div class="flow-nav">
|
||
{pills}
|
||
</div>
|
||
|
||
<!-- ══ MAIN ═════════════════════════════════════════════════════════════ -->
|
||
<div class="main">
|
||
<div class="content">
|
||
{tab_panes}
|
||
</div>
|
||
<div class="sidebar">
|
||
<div id="sidebar-node">
|
||
<h3>Node Details</h3>
|
||
<p class="hint" style="margin-top:6px">Click any node to inspect it.<br>Drag nodes to rearrange the layout.</p>
|
||
</div>
|
||
{_LEGEND_HTML}
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
var nodeData = {node_data_json};
|
||
var flowEdges = {flow_edges_js};
|
||
{EMBEDDED_JS}
|
||
</script>
|
||
</body>
|
||
</html>"""
|