add web GUI — dark hacker interface with real-time SSE output, vector picker, report viewer

This commit is contained in:
drjones
2026-06-19 15:41:58 -07:00
parent 104e0b6bd6
commit b3e614f1ba
2 changed files with 943 additions and 0 deletions

260
gui/server.py Normal file
View File

@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""The Analyzer — Web GUI Server
Run: python3 server.py [port]
Opens a dark hacker-themed web interface at http://localhost:5000
"""
import os
import sys
import json
import time
import queue
import threading
import subprocess
from pathlib import Path
from flask import Flask, render_template, request, jsonify, Response, send_from_directory, stream_with_context
# Paths
HERE = Path(__file__).parent
REPO = HERE.parent
ANALYZER = REPO / "analyzer"
REPORTS = REPO / "reports"
VECTORS = REPO / "vectors"
app = Flask(__name__, template_folder=HERE / "templates", static_folder=HERE / "static")
# Active scan tracking
active_scans = {}
scan_history = []
# ─── Helpers ──────────────────────────────────────────────
def get_vectors():
"""Return list of available vectors with metadata."""
vectors = []
for f in sorted(VECTORS.glob("*.sh")):
num = f.stem.split("-")[0]
name = "-".join(f.stem.split("-")[1:])
desc = ""
sev = ""
with open(f) as fh:
for line in fh:
if line.startswith("# Desc:"):
desc = line.replace("# Desc:", "").strip()
if line.startswith("# Severity:"):
sev = line.replace("# Severity:", "").strip()
vectors.append({
"num": int(num),
"name": name,
"file": f.name,
"desc": desc,
"severity": sev
})
return vectors
def get_reports():
"""Return list of completed reports."""
reports = []
for f in sorted(REPORTS.glob("*.md"), reverse=True):
if f.name.startswith("."):
continue
size = f.stat().st_size
target = "Unknown"
vulns = 0
date = ""
with open(f) as fh:
for line in fh:
if line.startswith("**Target:**"):
target = line.replace("**Target:**", "").strip()
if line.startswith("**Date:**"):
date = line.replace("**Date:**", "").strip()
if line.startswith("### "):
vulns += 1
html_path = f.with_suffix(".html")
has_html = html_path.exists()
reports.append({
"file": f.name,
"target": target,
"date": date,
"vulns": vulns,
"size": size,
"html": has_html
})
return reports
# ─── SSE Scan Runner ──────────────────────────────────────
class ScanRunner:
"""Runs the analyzer and streams output via SSE."""
def __init__(self, target, mode, vectors=None):
self.target = target
self.mode = mode
self.vectors = vectors
self.q = queue.Queue()
self.process = None
self.done = False
self.scan_id = str(int(time.time()))
self.start_time = time.time()
def run(self):
"""Run the scan in a thread, pushing output to queue."""
try:
env = os.environ.copy()
env["OLLAMA_HOST"] = env.get("OLLAMA_HOST", "http://10.30.20.110:11434")
env["OLLAMA_MODEL"] = env.get("OLLAMA_MODEL", "granite4.1:8b")
env["TERM"] = "xterm"
cmd = ["bash", str(ANALYZER), self.target, self.mode]
self.process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
env=env,
text=True,
bufsize=1,
cwd=str(REPO)
)
for line in iter(self.process.stdout.readline, ""):
if line:
self.q.put({"type": "output", "data": line.rstrip()})
self.process.wait()
# Find latest report
latest_report = None
reports = sorted(REPORTS.glob("*.md"), key=lambda x: x.stat().st_mtime, reverse=True)
for r in reports:
if not r.name.startswith("."):
latest_report = r.name
break
elapsed = time.time() - self.start_time
self.q.put({
"type": "complete",
"data": {
"exit_code": self.process.returncode,
"elapsed": f"{elapsed:.1f}s",
"report": latest_report
}
})
except Exception as e:
self.q.put({"type": "error", "data": str(e)})
finally:
self.done = True
def stream(self):
"""Generator for SSE streaming."""
while not self.done or not self.q.empty():
try:
msg = self.q.get(timeout=0.5)
yield f"data: {json.dumps(msg)}\n\n"
except queue.Empty:
yield ": keepalive\n\n"
# ─── Routes ───────────────────────────────────────────────
@app.route("/")
def index():
vectors = get_vectors()
reports = get_reports()
return render_template("index.html", vectors=vectors, reports=reports)
@app.route("/api/vectors")
def api_vectors():
return jsonify(get_vectors())
@app.route("/api/reports")
def api_reports():
return jsonify(get_reports())
@app.route("/api/scan", methods=["POST"])
def start_scan():
data = request.get_json()
target = data.get("target", "").strip()
mode = data.get("mode", "quick")
vector_list = data.get("vectors", "")
if not target:
return jsonify({"error": "Target URL required"}), 400
# Add https:// if missing
if not target.startswith("http"):
target = "https://" + target
runner = ScanRunner(target, mode, vector_list)
scan_id = runner.scan_id
active_scans[scan_id] = runner
thread = threading.Thread(target=runner.run, daemon=True)
thread.start()
return jsonify({"scan_id": scan_id, "status": "started"})
@app.route("/api/scan/<scan_id>/stream")
def scan_stream(scan_id):
runner = active_scans.get(scan_id)
if not runner:
return jsonify({"error": "Scan not found"}), 404
return Response(
stream_with_context(runner.stream()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
@app.route("/api/scan/<scan_id>/stop", methods=["POST"])
def stop_scan(scan_id):
runner = active_scans.get(scan_id)
if runner and runner.process:
runner.process.terminate()
return jsonify({"status": "stopped"})
return jsonify({"error": "Scan not found"}), 404
@app.route("/report/<filename>")
def view_report(filename):
md_path = REPORTS / filename
html_path = md_path.with_suffix(".html")
if html_path.exists():
return send_from_directory(str(REPORTS), html_path.name)
# Convert markdown to HTML on the fly
if md_path.exists():
content = md_path.read_text()
import markdown
html = markdown.markdown(content, extensions=["fenced_code", "tables"])
return f"""<!DOCTYPE html><html><head>
<meta charset="utf-8"><title>Analyzer Report</title>
<style>body{{font-family:-apple-system,sans-serif;background:#0f172a;color:#e2e8f0;max-width:800px;margin:40px auto;padding:20px;line-height:1.6}}
h1{{color:#f97316}} h2{{color:#f1f5f9;border-bottom:1px solid #334155;padding-bottom:8px}}
a{{color:#22d3ee}} code{{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:14px}}
pre{{background:#1e293b;padding:16px;border-radius:8px;overflow-x:auto}}
</style></head><body>{html}</body></html>"""
return "Report not found", 404
@app.route("/report/<filename>/raw")
def raw_report(filename):
md_path = REPORTS / filename
if md_path.exists():
return Response(md_path.read_text(), mimetype="text/plain")
return "Not found", 404
# ─── Main ─────────────────────────────────────────────────
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5000
print(f"\n ╔══════════════════════════════════════╗")
print(f" ║ THE ANALYZER — Web Interface ║")
print(f" ║ ║")
print(f" ║ http://localhost:{port}")
print(f" ╚══════════════════════════════════════╝\n")
app.run(host="0.0.0.0", port=port, debug=False, threaded=True)

683
gui/templates/index.html Normal file
View File

@@ -0,0 +1,683 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>THE ANALYZER — Autonomous Security Engine</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap');
:root {
--bg: #0a0e17;
--surface: #111827;
--surface2: #1e293b;
--border: #1e293b;
--text: #e2e8f0;
--text-dim: #64748b;
--accent: #f97316;
--accent2: #22d3ee;
--green: #22c55e;
--red: #ef4444;
--yellow: #eab308;
--blue: #3b82f6;
--purple: #a855f7;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
overflow: hidden;
}
/* ─── Sidebar ─── */
.sidebar {
width: 260px;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
flex-shrink: 0;
}
.logo {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
font-size: 14px;
line-height: 1.4;
color: var(--accent);
padding: 12px;
background: var(--bg);
border-radius: 8px;
border: 1px solid var(--border);
text-align: center;
}
.logo span { color: var(--text-dim); font-weight: 400; }
.nav {
display: flex;
flex-direction: column;
gap: 4px;
}
.nav-btn {
background: none;
border: none;
color: var(--text-dim);
padding: 10px 12px;
text-align: left;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-family: 'Inter', sans-serif;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 8px;
}
.nav-btn:hover { background: var(--surface2); color: var(--text); }
.nav-btn.active { background: var(--surface2); color: var(--accent); }
.nav-btn .icon { font-size: 16px; width: 20px; text-align: center; }
/* ─── Main ─── */
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.topbar {
padding: 16px 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.topbar h1 {
font-size: 18px;
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 8px;
}
.status-dot.idle { background: var(--text-dim); }
.status-dot.running { background: var(--green); animation: pulse 1.5s infinite; }
.status-dot.error { background: var(--red); }
.status-dot.done { background: var(--blue); }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.content {
flex: 1;
padding: 24px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 20px;
}
/* ─── Scan Panel ─── */
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
}
.panel-title {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
margin-bottom: 16px;
}
.input-group {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.input-group input {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 16px;
color: var(--text);
font-size: 14px;
font-family: 'JetBrains Mono', monospace;
outline: none;
transition: border 0.15s;
}
.input-group input:focus { border-color: var(--accent); }
.input-group input::placeholder { color: var(--text-dim); }
.mode-selector {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.mode-btn {
flex: 1;
padding: 8px 16px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-dim);
cursor: pointer;
font-size: 13px;
font-family: 'Inter', sans-serif;
transition: all 0.15s;
text-align: center;
}
.mode-btn:hover { border-color: var(--accent2); color: var(--text); }
.mode-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(249,115,22,0.1); }
.btn-primary {
width: 100%;
padding: 12px;
background: linear-gradient(135deg, var(--accent), #ea580c);
border: none;
border-radius: 8px;
color: white;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.15s;
font-family: 'Inter', sans-serif;
}
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 20px rgba(249,115,22,0.3); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
.btn-danger { background: var(--red); }
.btn-danger:hover { box-shadow: 0 4px 20px rgba(239,68,68,0.3); }
/* ─── Output Terminal ─── */
.terminal {
background: #000;
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
line-height: 1.5;
}
.terminal-header {
padding: 10px 16px;
background: #0a0a0a;
border-bottom: 1px solid #1a1a1a;
display: flex;
align-items: center;
justify-content: space-between;
}
.terminal-header span { color: var(--text-dim); font-size: 12px; }
.terminal-dots { display: flex; gap: 6px; }
.terminal-dot { width: 10px; height: 10px; border-radius: 50%; }
.terminal-dot.r { background: #ff5f56; }
.terminal-dot.y { background: #ffbd2e; }
.terminal-dot.g { background: #27c93f; }
.terminal-body {
padding: 16px;
height: 350px;
overflow-y: auto;
color: #a8b2c1;
}
.terminal-body::-webkit-scrollbar { width: 6px; }
.terminal-body::-webkit-scrollbar-track { background: #000; }
.terminal-body::-webkit-scrollbar-thumb { background: #1a1a1a; border-radius: 3px; }
.terminal-line { white-space: pre-wrap; word-break: break-all; margin-bottom: 2px; }
.terminal-line .find { color: var(--red); font-weight: 600; }
.terminal-line .ok { color: var(--green); }
.terminal-line .info { color: var(--blue); }
.terminal-line .warn { color: var(--yellow); }
.terminal-line .brain { color: var(--purple); }
.terminal-line .dim { color: var(--text-dim); }
.terminal-line .accent { color: var(--accent); }
/* ─── Vector Grid ─── */
.vector-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 8px;
}
.vector-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.vector-card:hover { border-color: var(--accent2); }
.vector-card.selected { border-color: var(--accent); background: rgba(249,115,22,0.08); }
.vector-card .num {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-dim);
}
.vector-card .name {
font-size: 13px;
font-weight: 500;
margin: 4px 0;
}
.vector-card .sev {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.sev-critical { color: var(--red); }
.sev-high { color: var(--accent); }
.sev-medium { color: var(--yellow); }
.sev-low { color: var(--blue); }
/* ─── Report List ─── */
.report-list { display: flex; flex-direction: column; gap: 8px; }
.report-item {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: all 0.15s;
}
.report-item:hover { border-color: var(--accent2); }
.report-item .target { font-weight: 500; font-size: 14px; }
.report-item .meta { font-size: 12px; color: var(--text-dim); }
.report-item .vulns { font-family: 'JetBrains Mono', monospace; font-weight: 600; }
.report-item .vulns.found { color: var(--red); }
.report-item .vulns.none { color: var(--green); }
.hidden { display: none !important; }
/* ─── Responsive ─── */
@media (max-width: 768px) {
.sidebar { width: 60px; padding: 12px 8px; }
.sidebar .logo { font-size: 10px; padding: 8px; }
.sidebar .nav-btn span { display: none; }
.main { padding: 0; }
.content { padding: 16px; }
.input-group { flex-direction: column; }
}
</style>
</head>
<body>
<!-- ─── Sidebar ─── -->
<div class="sidebar">
<div class="logo">
ANALYZER<br>
<span>v2.0</span>
</div>
<div class="nav">
<button class="nav-btn active" onclick="showPanel('scan')">
<span class="icon"></span> <span>Scan</span>
</button>
<button class="nav-btn" onclick="showPanel('vectors')">
<span class="icon">🎯</span> <span>Vectors</span>
</button>
<button class="nav-btn" onclick="showPanel('reports')">
<span class="icon">📄</span> <span>Reports</span>
</button>
</div>
</div>
<!-- ─── Main ─── -->
<div class="main">
<div class="topbar">
<h1><span class="status-dot idle" id="statusDot"></span><span id="statusText">Ready</span></h1>
<div style="display:flex;gap:8px;align-items:center;">
<span style="font-size:12px;color:var(--text-dim)" id="timer">00:00</span>
</div>
</div>
<div class="content">
<!-- ═══ SCAN PANEL ═══ -->
<div id="panel-scan" class="">
<div class="panel">
<div class="panel-title">New Scan</div>
<div class="input-group">
<input type="text" id="targetInput" placeholder="https://target.com" spellcheck="false">
</div>
<div class="mode-selector">
<button class="mode-btn active" data-mode="quick" onclick="setMode(this)">⚡ Quick</button>
<button class="mode-btn" data-mode="deep" onclick="setMode(this)">🔍 Deep</button>
<button class="mode-btn" data-mode="custom" onclick="setMode(this)">🎯 Custom</button>
</div>
<button class="btn-primary" id="scanBtn" onclick="startScan()">▶ Launch Scan</button>
<button class="btn-primary btn-danger hidden" id="stopBtn" onclick="stopScan()">■ Stop</button>
</div>
<!-- Custom vector selector (hidden by default) -->
<div class="panel hidden" id="vectorSelector">
<div class="panel-title">Select Vectors</div>
<div class="vector-grid" id="vectorGrid"></div>
</div>
<!-- Terminal Output -->
<div class="terminal hidden" id="terminal">
<div class="terminal-header">
<div class="terminal-dots">
<div class="terminal-dot r"></div>
<div class="terminal-dot y"></div>
<div class="terminal-dot g"></div>
</div>
<span>analyzer@kali:~$</span>
</div>
<div class="terminal-body" id="terminalBody"></div>
</div>
</div>
<!-- ═══ VECTORS PANEL ═══ -->
<div id="panel-vectors" class="hidden">
<div class="panel">
<div class="panel-title">Attack Vectors — 21 Total</div>
<div class="vector-grid" id="vectorGridFull"></div>
</div>
</div>
<!-- ═══ REPORTS PANEL ═══ -->
<div id="panel-reports" class="hidden">
<div class="panel">
<div class="panel-title">Scan Reports</div>
<div class="report-list" id="reportList"></div>
</div>
</div>
</div>
</div>
<script>
// ─── State ───
let currentMode = 'quick';
let selectedVectors = [];
let currentScanId = null;
// ─── Panel Navigation ───
function showPanel(name) {
document.querySelectorAll('.content > div').forEach(d => d.classList.add('hidden'));
document.getElementById('panel-' + name).classList.remove('hidden');
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
event.target.closest('.nav-btn').classList.add('active');
}
// ─── Mode Selection ───
function setMode(btn) {
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentMode = btn.dataset.mode;
const vecSel = document.getElementById('vectorSelector');
if (currentMode === 'custom') {
vecSel.classList.remove('hidden');
loadVectorSelector();
} else {
vecSel.classList.add('hidden');
}
}
// ─── Vector Selector ───
async function loadVectorSelector() {
const res = await fetch('/api/vectors');
const vectors = await res.json();
const grid = document.getElementById('vectorGrid');
grid.innerHTML = '';
vectors.forEach(v => {
const card = document.createElement('div');
card.className = 'vector-card' + (selectedVectors.includes(v.num) ? ' selected' : '');
card.innerHTML = `
<div class="num">#${String(v.num).padStart(2,'0')}</div>
<div class="name">${v.name}</div>
<div class="sev sev-${v.severity.toLowerCase()}">${v.severity}</div>
`;
card.onclick = () => {
const idx = selectedVectors.indexOf(v.num);
if (idx >= 0) selectedVectors.splice(idx, 1);
else selectedVectors.push(v.num);
card.classList.toggle('selected');
};
grid.appendChild(card);
});
}
// ─── Vectors Full List ───
async function loadVectorsFull() {
const res = await fetch('/api/vectors');
const vectors = await res.json();
const grid = document.getElementById('vectorGridFull');
if (grid.children.length > 0) return; // already loaded
grid.innerHTML = '';
vectors.forEach(v => {
const card = document.createElement('div');
card.className = 'vector-card';
card.innerHTML = `
<div class="num">#${String(v.num).padStart(2,'0')}</div>
<div class="name">${v.name}</div>
<div class="sev sev-${v.severity.toLowerCase()}">${v.severity}</div>
<div style="font-size:11px;color:var(--text-dim);margin-top:4px;">${v.desc}</div>
`;
grid.appendChild(card);
});
}
// ─── Reports List ───
async function loadReports() {
const res = await fetch('/api/reports');
const reports = await res.json();
const list = document.getElementById('reportList');
list.innerHTML = '';
if (reports.length === 0) {
list.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-dim)">No reports yet. Run a scan!</div>';
return;
}
reports.forEach(r => {
const item = document.createElement('div');
item.className = 'report-item';
const vulnClass = r.vulns > 0 ? 'found' : 'none';
item.innerHTML = `
<div>
<div class="target">${r.target}</div>
<div class="meta">${r.date} · ${(r.size/1024).toFixed(1)} KB</div>
</div>
<div class="vulns ${vulnClass}">${r.vulns > 0 ? '⚠ ' + r.vulns : '✅ 0'} vulns</div>
`;
item.onclick = () => window.open('/report/' + r.file, '_blank');
list.appendChild(item);
});
}
// ─── Terminal output ───
function escapeHtml(str) {
const div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
function appendTerminal(text) {
const body = document.getElementById('terminalBody');
const line = document.createElement('div');
line.className = 'terminal-line';
// Sanitize then colorize
const safe = escapeHtml(text);
const html = safe
.replace(/💥.*/g, m => `<span class="find">${m}</span>`)
.replace(/✅.*/g, m => `<span class="ok">${m}</span>`)
.replace(/.*/g, m => `<span class="info">${m}</span>`)
.replace(/⚠️.*/g, m => `<span class="warn">${m}</span>`)
.replace(/🧠.*/g, m => `<span class="brain">${m}</span>`)
.replace(/➜.*/g, m => `<span class="dim">${m}</span>`)
.replace(/🎯.*/g, m => `<span class="accent">${m}</span>`)
.replace(/❌.*/g, m => `<span class="find">${m}</span>`);
line.innerHTML = html;
body.appendChild(line);
body.scrollTop = body.scrollHeight;
}
// ─── Timer ───
let timerInterval = null;
function startTimer() {
const start = Date.now();
timerInterval = setInterval(() => {
const elapsed = Math.floor((Date.now() - start) / 1000);
const m = String(Math.floor(elapsed / 60)).padStart(2, '0');
const s = String(elapsed % 60).padStart(2, '0');
document.getElementById('timer').textContent = `${m}:${s}`;
}, 1000);
}
function stopTimer() {
if (timerInterval) clearInterval(timerInterval);
}
// ─── Scan ───
async function startScan() {
const target = document.getElementById('targetInput').value.trim();
if (!target) {
alert('Enter a target URL');
return;
}
// Show terminal
document.getElementById('terminal').classList.remove('hidden');
document.getElementById('terminalBody').innerHTML = '';
document.getElementById('scanBtn').classList.add('hidden');
document.getElementById('stopBtn').classList.remove('hidden');
// Status
document.getElementById('statusDot').className = 'status-dot running';
document.getElementById('statusText').textContent = 'Scanning...';
startTimer();
const payload = {
target: target,
mode: currentMode,
vectors: selectedVectors.join(',')
};
appendTerminal('🎯 Target: ' + target);
appendTerminal('⚡ Mode: ' + currentMode);
appendTerminal('───────────────');
try {
const res = await fetch('/api/scan', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const data = await res.json();
currentScanId = data.scan_id;
// Connect to SSE stream
const evtSource = new EventSource(`/api/scan/${data.scan_id}/stream`);
evtSource.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'output') {
appendTerminal(msg.data);
} else if (msg.type === 'complete') {
evtSource.close();
finishScan(msg.data);
} else if (msg.type === 'error') {
appendTerminal('❌ ' + msg.data);
evtSource.close();
finishScan({exit_code: -1});
}
};
evtSource.onerror = () => {
evtSource.close();
appendTerminal('⚠️ Connection lost. Scan may still be running.');
};
} catch (e) {
appendTerminal('❌ Failed to start scan: ' + e.message);
finishScan({});
}
}
function finishScan(data) {
stopTimer();
document.getElementById('statusDot').className = data.exit_code === 0 ? 'status-dot done' : 'status-dot error';
document.getElementById('statusText').textContent = data.exit_code === 0 ? 'Complete' : 'Failed';
document.getElementById('scanBtn').classList.remove('hidden');
document.getElementById('stopBtn').classList.add('hidden');
if (data.report) {
appendTerminal('');
appendTerminal('📄 Report: /report/' + data.report);
appendTerminal('✨ Scan complete in ' + (data.elapsed || '?'));
} else if (data.exit_code === 0) {
appendTerminal('✨ Scan complete');
}
loadReports();
}
async function stopScan() {
if (!currentScanId) return;
await fetch(`/api/scan/${currentScanId}/stop`, {method: 'POST'});
appendTerminal('⏹️ Scan stopped by user');
finishScan({exit_code: -1});
}
// ─── Init ───
loadVectorSelector();
document.querySelector('[onclick*="vectors"]').onclick = () => { showPanel('vectors'); loadVectorsFull(); };
document.querySelector('[onclick*="reports"]').onclick = () => { showPanel('reports'); loadReports(); };
// Enter key starts scan
document.getElementById('targetInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') startScan();
});
</script>
</body>
</html>