<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PHALUS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;700;800&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0a0a;
--surface: #111;
--border: #222;
--dim: #333;
--accent: #ff3e3e;
--accent2: #ff6b35;
--green: #00ff41;
--yellow: #ffd700;
--cyan: #00e5ff;
--text: #e8e8e8;
--muted: #555;
}
body {
background: var(--bg);
color: var(--text);
font-family: 'IBM Plex Mono', 'JetBrains Mono', 'Courier New', monospace;
font-size: 15px;
line-height: 1.6;
min-height: 100vh;
padding-bottom: 40px;
}
header {
background: var(--bg);
border-bottom: 1px solid var(--border);
padding: 0 24px;
height: 48px;
display: flex;
align-items: center;
gap: 0;
position: sticky;
top: 0;
z-index: 100;
}
.logo {
font-family: 'JetBrains Mono', monospace;
font-size: 17px;
font-weight: 800;
letter-spacing: 4px;
color: var(--accent);
margin-right: 24px;
flex-shrink: 0;
}
.tab-bar {
display: flex;
gap: 0;
height: 100%;
}
.tab-btn {
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 0 16px;
font-family: 'IBM Plex Mono', monospace;
font-size: 13px;
font-weight: 500;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--muted);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
height: 100%;
display: flex;
align-items: center;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.active { color: var(--text); border-bottom-color: var(--accent); }
.header-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
.version-label {
font-size: 12px;
color: var(--muted);
letter-spacing: 1px;
}
.health-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--muted);
}
.health-dot.ok { background: var(--green); }
.health-dot.error { background: var(--accent); }
main {
max-width: 980px;
margin: 24px auto;
padding: 0 24px;
}
.view { display: none; }
.view.active { display: block; }
.grid-2x2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.grid-scan {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 16px;
}
@media (max-width: 700px) {
.grid-2x2, .grid-scan { grid-template-columns: 1fr; }
}
.card {
background: var(--surface);
border: 1px solid var(--border);
padding: 16px;
}
.card-label {
font-size: 11px;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 12px;
}
.span-2 { grid-column: 1 / -1; }
label {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 4px;
}
textarea, input[type="text"], input[type="number"], select {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: 'IBM Plex Mono', monospace;
font-size: 14px;
padding: 7px 10px;
outline: none;
transition: border-color 0.15s;
}
textarea:focus, input:focus, select:focus {
border-color: var(--accent);
}
textarea {
resize: vertical;
min-height: 120px;
font-size: 13px;
}
select { cursor: pointer; }
select option { background: var(--surface); }
.field { margin-bottom: 12px; }
.checkbox-row {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
color: var(--muted);
}
.checkbox-row label {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 0;
cursor: pointer;
}
input[type="checkbox"] {
appearance: none;
width: 14px;
height: 14px;
border: 1px solid var(--border);
background: var(--bg);
cursor: pointer;
position: relative;
}
input[type="checkbox"]:checked {
border-color: var(--accent);
}
input[type="checkbox"]:checked::after {
content: '';
position: absolute;
top: 2px; left: 2px; right: 2px; bottom: 2px;
background: var(--accent);
}
.dropzone {
border: 1px dashed var(--dim);
padding: 14px;
text-align: center;
cursor: pointer;
color: var(--muted);
font-size: 13px;
transition: border-color 0.15s;
margin-bottom: 8px;
}
.dropzone:hover { border-color: var(--muted); }
.dropzone.drag-over {
border-color: var(--accent);
background: rgba(255, 62, 62, 0.03);
}
.dropzone .hint {
display: block;
margin-top: 4px;
font-size: 11px;
color: var(--dim);
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
border: 1px solid var(--border);
background: none;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
cursor: pointer;
transition: opacity 0.15s, border-color 0.15s, color 0.15s;
color: var(--muted);
}
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
.btn:not(:disabled):hover { opacity: 0.85; }
.btn-accent {
border-color: var(--accent);
color: var(--accent);
}
.btn-accent:not(:disabled):hover {
background: var(--accent);
color: var(--bg);
}
.btn-success {
border-color: var(--green);
color: var(--green);
}
.btn-danger {
border-color: var(--accent);
color: var(--accent);
}
.btn-row {
display: flex;
gap: 8px;
margin-top: 14px;
flex-wrap: wrap;
}
.btn-full { width: 100%; justify-content: center; }
.progress-list {
list-style: none;
max-height: 200px;
overflow-y: auto;
font-size: 13px;
scrollbar-width: thin;
scrollbar-color: var(--border) var(--bg);
}
.progress-list li {
padding: 2px 0;
border-bottom: 1px solid var(--bg);
color: var(--muted);
}
.progress-list li.ok { color: var(--green); }
.progress-list li.fail { color: var(--accent); }
.progress-list li.info { color: var(--cyan); }
.progress-empty {
color: var(--muted);
font-size: 13px;
font-style: italic;
}
.progress-counter {
font-size: 13px;
color: var(--cyan);
margin-bottom: 8px;
font-weight: 600;
}
.results-placeholder {
color: var(--muted);
font-size: 13px;
font-style: italic;
}
.result-card {
background: var(--bg);
border: 1px solid var(--border);
padding: 10px 14px;
margin-bottom: 6px;
display: flex;
justify-content: space-between;
align-items: center;
}
.result-card .pkg-info { display: flex; flex-direction: column; gap: 1px; }
.result-card .pkg-name {
font-size: 14px;
font-weight: 600;
}
.result-card .pkg-name .ver { color: var(--muted); font-weight: 400; }
.result-card .pkg-meta {
font-size: 11px;
color: var(--muted);
}
.pkg-badge {
font-weight: 700;
font-size: 11px;
padding: 2px 8px;
letter-spacing: 1px;
text-transform: uppercase;
}
.pkg-badge.pass {
background: rgba(0, 255, 65, 0.12);
color: var(--green);
}
.pkg-badge.fail {
background: rgba(255, 62, 62, 0.12);
color: var(--accent);
}
.download-banner {
background: var(--bg);
border: 1px solid var(--border);
padding: 14px;
margin-bottom: 12px;
text-align: center;
}
.download-banner .summary-text {
font-size: 16px;
font-weight: 600;
margin-bottom: 10px;
}
.download-banner .btn {
text-decoration: none;
display: inline-flex;
}
.output-path-row {
margin-top: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 13px;
color: var(--muted);
}
.output-path-row code {
color: var(--cyan);
background: var(--surface);
padding: 3px 8px;
border: 1px solid var(--border);
}
.copy-btn {
background: none;
border: 1px solid var(--border);
color: var(--muted);
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
padding: 3px 8px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.copy-btn:hover {
color: var(--text);
border-color: var(--muted);
}
.parsed-packages {
list-style: none;
margin-top: 8px;
}
.parsed-packages li {
display: flex;
align-items: center;
gap: 8px;
padding: 3px 0;
border-bottom: 1px solid var(--bg);
font-size: 13px;
}
.parsed-packages li:last-child { border-bottom: none; }
.eco-badge {
background: rgba(255, 62, 62, 0.1);
color: var(--accent2);
font-size: 11px;
font-weight: 600;
padding: 1px 5px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.scan-summary-number {
font-size: 30px;
font-weight: 700;
color: var(--text);
margin-bottom: 2px;
}
.scan-summary-label {
font-size: 11px;
color: var(--muted);
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 16px;
}
.scan-stat-row {
display: flex;
justify-content: space-between;
font-size: 13px;
padding: 4px 0;
}
.license-bar {
height: 6px;
display: flex;
overflow: hidden;
margin-top: 12px;
}
.license-bar div { transition: width 0.3s; }
.scan-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.scan-table th {
text-align: left;
padding: 6px 0;
font-size: 11px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--muted);
border-bottom: 1px solid var(--border);
font-weight: 400;
}
.scan-table td {
padding: 7px 0;
border-bottom: 1px solid #1a1a1a;
}
.scan-table td:first-child { color: var(--text); font-weight: 600; }
.scan-table .license-cell { color: var(--text); }
.class-permissive { color: var(--green); font-weight: 600; font-size: 12px; }
.class-copyleft-weak { color: var(--yellow); font-weight: 600; font-size: 12px; }
.class-copyleft-strong { color: var(--accent); font-weight: 600; font-size: 12px; }
.class-proprietary { color: var(--accent); font-weight: 600; font-size: 12px; }
.class-unknown { color: var(--muted); font-weight: 600; font-size: 12px; }
.scan-more {
text-align: center;
color: var(--muted);
font-size: 12px;
padding: 8px 0;
cursor: pointer;
}
.scan-more:hover { color: var(--text); }
.history-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.history-table th {
text-align: left;
padding: 6px 0;
font-size: 11px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--muted);
border-bottom: 1px solid var(--border);
font-weight: 400;
}
.history-table td {
padding: 7px 0;
border-bottom: 1px solid #1a1a1a;
color: var(--muted);
}
.history-table td:first-child { color: var(--text); }
.view-link {
color: var(--accent);
cursor: pointer;
font-size: 12px;
}
.view-link:hover { color: var(--accent2); }
#status-bar {
background: var(--bg);
border-top: 1px solid var(--border);
padding: 6px 24px;
font-size: 12px;
color: var(--muted);
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
}
.status-left {
display: flex;
align-items: center;
gap: 6px;
}
.status-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--muted);
}
.status-dot.idle { background: var(--muted); }
.status-dot.ok { background: var(--green); }
.status-dot.busy { background: var(--yellow); animation: pulse 1s infinite; }
.status-dot.error { background: var(--accent); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.status-right {
color: var(--dim);
}
@media (max-width: 700px) {
header { padding: 0 12px; }
main { padding: 0 12px; margin: 16px auto; }
.logo { font-size: 14px; letter-spacing: 2px; margin-right: 12px; }
.tab-btn { padding: 0 10px; font-size: 12px; }
}
</style>
</head>
<body>
<header>
<span class="logo">PHALUS</span>
<div class="tab-bar">
<button class="tab-btn active" data-tab="pipeline">Pipeline</button>
<button class="tab-btn" data-tab="scan">Scan</button>
</div>
<div class="header-right">
<span class="version-label">v0.6.0</span>
<span class="health-dot" id="health-dot" title="Checking..."></span>
</div>
</header>
<main>
<div class="view active" id="view-pipeline">
<div class="grid-2x2">
<div class="card">
<div class="card-label">// Input</div>
<div id="dropzone" class="dropzone" role="button" tabindex="0"
aria-label="Drop manifest file here or click to browse">
Drop manifest file here
<span class="hint">package.json · requirements.txt · Cargo.toml · go.mod</span>
</div>
<input type="file" id="file-input" style="display:none"
accept=".json,.txt,.toml,.mod,.lock" />
<div class="field">
<label for="manifest-text">or paste manifest content</label>
<textarea id="manifest-text" placeholder='{ "dependencies": { "lodash": "^4.17.21" } }'></textarea>
</div>
<button class="btn btn-full" id="parse-btn">Parse</button>
<div id="parse-result" style="margin-top:8px;font-size:13px;color:var(--muted);"></div>
</div>
<div class="card">
<div class="card-label">// Options</div>
<div class="field">
<label for="run-one-input">Single package (ecosystem/name@version)</label>
<input id="run-one-input" type="text" placeholder="npm/lodash@4.17.21" />
</div>
<div class="field">
<label for="license-select">Output license</label>
<select id="license-select">
<option value="mit">MIT</option>
<option value="apache-2.0">Apache 2.0</option>
<option value="bsd-2">BSD 2-Clause</option>
<option value="bsd-3">BSD 3-Clause</option>
<option value="isc">ISC</option>
<option value="unlicense">Unlicense</option>
<option value="cc0">CC0</option>
</select>
</div>
<div class="field">
<label for="target-lang-select">Target language</label>
<select id="target-lang-select">
<option value="same">Same as source</option>
<option value="rust">Rust</option>
<option value="go">Go</option>
<option value="python">Python</option>
<option value="typescript">TypeScript</option>
</select>
</div>
<div class="checkbox-row" style="margin-bottom:12px;">
<label><input type="checkbox" id="resume-check" /> Resume (skip completed packages)</label>
</div>
<div class="btn-row">
<button class="btn btn-accent" id="start-btn">▶ Start</button>
<button class="btn btn-danger" id="stop-btn" disabled>■ Stop</button>
<button class="btn" id="clear-btn">Clear</button>
</div>
</div>
<div class="card">
<div class="card-label">// Progress</div>
<div class="progress-counter" id="progress-counter" style="display:none"></div>
<p class="progress-empty" id="progress-empty">No job running.</p>
<ul class="progress-list" id="progress-list" style="display:none"></ul>
</div>
<div class="card" id="results-card">
<div class="card-label">// Results</div>
<p class="results-placeholder" id="results-empty">Results will appear here after a job completes.</p>
<div id="results-list"></div>
</div>
</div>
</div>
<div class="view" id="view-scan">
<div class="grid-scan">
<div>
<div class="card" style="margin-bottom:16px;">
<div class="card-label">// Scan</div>
<div class="field">
<label for="scan-path">Directory or manifest path</label>
<input id="scan-path" type="text" placeholder="./my-project" />
</div>
<div class="checkbox-row" style="margin-bottom:12px;">
<label><input type="checkbox" id="scan-offline" /> Offline</label>
<div>
<label for="scan-concurrency" style="display:inline;margin-bottom:0;">Concurrency</label>
<input id="scan-concurrency" type="number" value="8" min="1" max="32"
style="width:50px;display:inline;padding:4px 6px;font-size:13px;margin-left:4px;" />
</div>
</div>
<button class="btn btn-accent btn-full" id="scan-btn">Scan</button>
<div id="scan-error" style="margin-top:8px;font-size:13px;color:var(--accent);display:none;"></div>
</div>
<div class="card" id="scan-summary" style="display:none;">
<div class="card-label">// Summary</div>
<div class="scan-summary-number" id="scan-total">0</div>
<div class="scan-summary-label">packages scanned</div>
<div id="scan-stats"></div>
<div class="license-bar" id="license-bar"></div>
</div>
</div>
<div class="card" id="scan-results-card">
<div class="card-label">// Packages</div>
<p class="results-placeholder" id="scan-results-empty">Run a scan or select one from history.</p>
<div id="scan-results-table"></div>
</div>
</div>
<div class="card" style="margin-top:16px;" id="scan-history-card">
<div class="card-label" style="display:flex;justify-content:space-between;align-items:center;">
<span>// Scan History</span>
<button class="btn" id="clear-history-btn" style="font-size:11px;padding:2px 8px;" onclick="clearScanHistory()">Clear</button>
</div>
<p class="results-placeholder" id="scan-history-empty">No saved scans yet.</p>
<div id="scan-history-table"></div>
</div>
</div>
</main>
<div id="status-bar">
<div class="status-left">
<span class="status-dot idle" id="status-dot"></span>
<span id="status-text">Idle</span>
</div>
<div class="status-right">phalus v0.6.0</div>
</div>
<script>
"use strict";
var tabBtns = document.querySelectorAll('.tab-btn');
tabBtns.forEach(function(btn) {
btn.addEventListener('click', function() {
var tab = btn.getAttribute('data-tab');
tabBtns.forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
document.querySelectorAll('.view').forEach(function(v) { v.classList.remove('active'); });
document.getElementById('view-' + tab).classList.add('active');
if (tab === 'scan') loadScanHistory();
});
});
function escapeHtml(s) {
var div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function setStatus(msg, kind) {
document.getElementById('status-text').textContent = msg;
document.getElementById('status-dot').className = 'status-dot ' + (kind || 'idle');
}
function timeAgo(dateStr) {
var now = Date.now();
var then = new Date(dateStr).getTime();
var diff = Math.floor((now - then) / 1000);
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
}
var outputDir = './phalus-output';
(function checkHealth() {
fetch('/api/health').then(function(r) {
if (r.ok) {
document.getElementById('health-dot').className = 'health-dot ok';
document.getElementById('health-dot').title = 'API ok';
return r.json();
} else {
throw new Error('non-ok');
}
}).then(function(data) {
if (data && data.output_dir) outputDir = data.output_dir;
}).catch(function() {
document.getElementById('health-dot').className = 'health-dot error';
document.getElementById('health-dot').title = 'API unreachable';
});
})();
var currentJobId = null;
var totalPackages = 0;
var processedPackages = 0;
var packageStartTimes = {};
var jobRunning = false;
var eventSource = null;
var dropzone = document.getElementById('dropzone');
var fileInput = document.getElementById('file-input');
var textArea = document.getElementById('manifest-text');
dropzone.addEventListener('click', function() { fileInput.click(); });
dropzone.addEventListener('keydown', function(e) { if (e.key === 'Enter' || e.key === ' ') fileInput.click(); });
dropzone.addEventListener('dragover', function(e) { e.preventDefault(); dropzone.classList.add('drag-over'); });
dropzone.addEventListener('dragleave', function() { dropzone.classList.remove('drag-over'); });
dropzone.addEventListener('drop', function(e) {
e.preventDefault();
dropzone.classList.remove('drag-over');
if (e.dataTransfer.files[0]) readFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', function() {
if (fileInput.files[0]) readFile(fileInput.files[0]);
});
function readFile(file) {
var reader = new FileReader();
reader.onload = function(ev) { textArea.value = ev.target.result; };
reader.readAsText(file);
dropzone.textContent = file.name;
}
document.getElementById('parse-btn').addEventListener('click', function() {
var body = textArea.value.trim();
if (!body) { setParseResult('Paste or drop a manifest first.', 'warn'); return; }
fetch('/api/manifest/parse', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: body
}).then(function(r) { return r.json(); }).then(function(json) {
if (json.error) {
setParseResult('Error: ' + json.error, 'fail');
} else {
showParsedPackages(json);
}
}).catch(function(err) {
setParseResult('Error: ' + err.message, 'fail');
});
});
function setParseResult(msg, kind) {
var el = document.getElementById('parse-result');
el.textContent = msg;
el.style.color = kind === 'ok' ? 'var(--green)' : kind === 'fail' ? 'var(--accent)' : 'var(--yellow)';
}
function showParsedPackages(manifest) {
var el = document.getElementById('parse-result');
el.style.color = 'var(--green)';
var pkgs = manifest.packages || [];
totalPackages = pkgs.length;
var html = '<div style="margin-bottom:6px;color:var(--green);font-weight:600;">' +
escapeHtml(String(pkgs.length)) + ' package' + (pkgs.length !== 1 ? 's' : '') +
' detected (' + escapeHtml(manifest.manifest_type || 'unknown') + ')</div>';
html += '<ul class="parsed-packages">';
for (var i = 0; i < pkgs.length; i++) {
var pkg = pkgs[i];
html += '<li>' +
'<span class="eco-badge">' + escapeHtml(pkg.ecosystem || 'unknown') + '</span>' +
'<span style="font-weight:600;">' + escapeHtml(pkg.name || '?') + '</span>' +
'<span style="color:var(--muted);">' + escapeHtml(pkg.version_constraint || '*') + '</span>' +
'</li>';
}
html += '</ul>';
el.innerHTML = html;
}
function addProgress(msg, kind) {
var list = document.getElementById('progress-list');
var empty = document.getElementById('progress-empty');
empty.style.display = 'none';
list.style.display = '';
var li = document.createElement('li');
li.textContent = msg;
if (kind) li.className = kind;
list.appendChild(li);
list.scrollTop = list.scrollHeight;
}
function updateProgressCounter(current, total) {
var counter = document.getElementById('progress-counter');
counter.style.display = '';
counter.textContent = 'Processing ' + current + ' of ' + total + '...';
}
function clearProgress() {
var list = document.getElementById('progress-list');
list.innerHTML = '';
list.style.display = 'none';
document.getElementById('progress-empty').style.display = '';
var counter = document.getElementById('progress-counter');
counter.style.display = 'none';
counter.textContent = '';
}
function clearResults() {
document.getElementById('results-list').innerHTML = '';
document.getElementById('results-empty').style.display = '';
document.getElementById('results-card').className = 'card';
}
function addResultCard(pkg, version, pass, elapsed, error) {
document.getElementById('results-empty').style.display = 'none';
document.getElementById('results-card').className = 'card span-2';
var card = document.createElement('div');
card.className = 'result-card';
var badgeClass = pass ? 'pass' : 'fail';
var badgeText = pass ? 'PASS' : 'FAIL';
var elapsedText = elapsed ? ' — ' + elapsed + 's' : '';
var errorHtml = error ? '<span class="pkg-meta" style="color:var(--accent)">' + escapeHtml(error) + '</span>' : '';
card.innerHTML =
'<div class="pkg-info">' +
'<span class="pkg-name">' + escapeHtml(pkg) + '<span class="ver">@' + escapeHtml(version) + '</span></span>' +
'<span class="pkg-meta">Completed' + escapeHtml(elapsedText) + '</span>' +
errorHtml +
'</div>' +
'<span class="pkg-badge ' + badgeClass + '">' + badgeText + '</span>';
document.getElementById('results-list').appendChild(card);
}
document.getElementById('start-btn').addEventListener('click', startJob);
document.getElementById('stop-btn').addEventListener('click', stopJob);
function startJob() {
var runOne = document.getElementById('run-one-input').value.trim();
var manifestContent = textArea.value.trim();
if (!runOne && !manifestContent) {
setStatus('Provide a package spec or manifest.', 'error');
return;
}
clearProgress();
clearResults();
processedPackages = 0;
packageStartTimes = {};
jobRunning = true;
document.getElementById('start-btn').disabled = true;
document.getElementById('stop-btn').disabled = false;
setStatus('Job running...', 'busy');
var jobManifest = manifestContent;
if (runOne && !manifestContent) {
var parts = runOne.split('/');
if (parts.length === 2) {
var eco = parts[0];
var rest = parts[1] || '';
var atIdx = rest.indexOf('@');
var name = atIdx >= 0 ? rest.substring(0, atIdx) : rest;
var version = atIdx >= 0 ? rest.substring(atIdx + 1) : 'latest';
if (eco === 'npm' && name) {
var deps = {};
deps[name] = version;
jobManifest = JSON.stringify({ dependencies: deps });
}
}
if (!jobManifest) jobManifest = runOne;
}
addProgress('Submitting job...', 'info');
var license = document.getElementById('license-select').value;
var resume = document.getElementById('resume-check').checked;
fetch('/api/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ manifest_content: jobManifest, license: license, isolation: 'context', resume: resume })
}).then(function(r) {
if (!r.ok) return r.json().catch(function() { return { error: 'unknown' }; }).then(function(err) { throw new Error(err.error || r.statusText); });
return r.json();
}).then(function(data) {
currentJobId = data.job_id;
addProgress('Job created: ' + data.job_id, 'info');
eventSource = new EventSource('/api/jobs/' + data.job_id + '/stream');
eventSource.onmessage = function(ev) {
try {
var d = JSON.parse(ev.data);
if (d.PackageStarted) {
processedPackages++;
packageStartTimes[d.PackageStarted.name] = Date.now();
if (totalPackages > 0) updateProgressCounter(processedPackages, totalPackages);
addProgress('Started: ' + d.PackageStarted.name, 'info');
} else if (d.PhaseDone) {
var phaseLabel = d.PhaseDone.phase === 'agent_b_started'
? 'Agent B (builder) running — this may take several minutes...'
: d.PhaseDone.phase;
addProgress(' ' + d.PhaseDone.name + ' \u2192 ' + phaseLabel, d.PhaseDone.phase === 'agent_b_started' ? 'info' : '');
} else if (d.AgentIteration) {
addProgress(' ' + d.AgentIteration.name + ' \u2192 iteration ' +
d.AgentIteration.iteration + '/' + d.AgentIteration.max_iterations +
' \u2014 ' + d.AgentIteration.detail, 'info');
} else if (d.PackageDone) {
var ok = d.PackageDone.success;
var pkgName = d.PackageDone.name;
var pkgError = d.PackageDone.error || null;
var elapsed = null;
if (packageStartTimes[pkgName]) elapsed = ((Date.now() - packageStartTimes[pkgName]) / 1000).toFixed(1);
var progressMsg = (ok ? 'OK' : 'FAIL') + ' ' + pkgName + (elapsed ? ' (' + elapsed + 's)' : '');
if (pkgError) progressMsg += ' — ' + pkgError;
addProgress(progressMsg, ok ? 'ok' : 'fail');
addResultCard(pkgName, 'latest', ok, elapsed, pkgError);
} else if (d.JobDone != null) {
var total = d.JobDone.total;
var failed = d.JobDone.failed;
addProgress('Job done: ' + total + ' packages, ' + failed + ' failed', 'ok');
document.getElementById('progress-counter').textContent = 'Completed: ' + total + ' packages';
var resultsDiv = document.getElementById('results-list');
var summary = document.createElement('div');
summary.className = 'download-banner';
var failHtml = failed > 0
? '<span style="color:var(--accent)">' + escapeHtml(String(failed)) + ' failed</span>'
: '<span style="color:var(--green)">0 failed</span>';
var downloadBtn = (failed < total)
? '<a href="/api/jobs/' + escapeHtml(currentJobId) + '/download" class="btn btn-success" style="text-decoration:none;">Download Output (.zip)</a>'
: '';
var pathRow = '<div class="output-path-row"><code id="output-path-text">' + escapeHtml(outputDir) + '</code>' +
'<button class="copy-btn" onclick="var t=document.getElementById(\'output-path-text\').textContent;navigator.clipboard.writeText(t).then(function(){this.textContent=\'Copied!\';var b=this;setTimeout(function(){b.textContent=\'Copy\'},1500)}.bind(this))">Copy</button></div>';
summary.innerHTML =
'<div class="summary-text">' + escapeHtml(String(total)) + ' packages processed, ' + failHtml + '</div>' +
downloadBtn + pathRow;
resultsDiv.insertBefore(summary, resultsDiv.firstChild);
setStatus('Idle — job completed.', 'ok');
jobRunning = false;
document.getElementById('start-btn').disabled = false;
document.getElementById('stop-btn').disabled = true;
if (eventSource) { eventSource.close(); eventSource = null; }
}
} catch (e) {
addProgress('Event: ' + ev.data, '');
}
};
eventSource.onerror = function() {
if (jobRunning) {
addProgress('SSE connection lost.', 'fail');
setStatus('Connection lost.', 'error');
}
if (eventSource) { eventSource.close(); eventSource = null; }
jobRunning = false;
document.getElementById('start-btn').disabled = false;
document.getElementById('stop-btn').disabled = true;
};
}).catch(function(err) {
addProgress('Error: ' + err.message, 'fail');
setStatus('Job failed.', 'error');
jobRunning = false;
document.getElementById('start-btn').disabled = false;
document.getElementById('stop-btn').disabled = true;
});
}
function stopJob() {
if (eventSource) { eventSource.close(); eventSource = null; }
jobRunning = false;
document.getElementById('start-btn').disabled = false;
document.getElementById('stop-btn').disabled = true;
addProgress('Job stopped by user.', 'fail');
setStatus('Idle — job stopped.', 'idle');
}
document.getElementById('clear-btn').addEventListener('click', function() {
textArea.value = '';
document.getElementById('run-one-input').value = '';
document.getElementById('parse-result').textContent = '';
dropzone.innerHTML = 'Drop manifest file here<span class="hint">package.json · requirements.txt · Cargo.toml · go.mod</span>';
clearProgress();
clearResults();
currentJobId = null;
totalPackages = 0;
processedPackages = 0;
packageStartTimes = {};
setStatus('Idle', 'idle');
});
var SCAN_PAGE_SIZE = 20;
var currentScanPackages = [];
var scanShowAll = false;
document.getElementById('scan-btn').addEventListener('click', runScan);
function runScan() {
var path = document.getElementById('scan-path').value.trim();
if (!path) {
showScanError('Enter a directory or manifest path.');
return;
}
hideScanError();
document.getElementById('scan-btn').disabled = true;
document.getElementById('scan-btn').textContent = 'SCANNING...';
setStatus('Scanning...', 'busy');
var offline = document.getElementById('scan-offline').checked;
var concurrency = parseInt(document.getElementById('scan-concurrency').value, 10) || 8;
fetch('/api/scans', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, offline: offline, concurrency: concurrency })
}).then(function(r) {
if (!r.ok) return r.json().catch(function() { return { error: 'scan failed' }; }).then(function(err) { throw new Error(err.error || 'scan failed'); });
return r.json();
}).then(function(result) {
displayScanResult(result);
setStatus('Scan complete — ' + result.packages.length + ' packages.', 'ok');
loadScanHistory();
}).catch(function(err) {
showScanError(err.message);
setStatus('Scan failed.', 'error');
}).finally(function() {
document.getElementById('scan-btn').disabled = false;
document.getElementById('scan-btn').textContent = 'SCAN';
});
}
function showScanError(msg) {
var el = document.getElementById('scan-error');
el.textContent = msg;
el.style.display = '';
}
function hideScanError() {
document.getElementById('scan-error').style.display = 'none';
}
function displayScanResult(result) {
var pkgs = result.packages || [];
currentScanPackages = pkgs;
scanShowAll = false;
var summary = document.getElementById('scan-summary');
summary.style.display = '';
document.getElementById('scan-total').textContent = pkgs.length;
var counts = { permissive: 0, 'copyleft-weak': 0, 'copyleft-strong': 0, proprietary: 0, unknown: 0 };
for (var i = 0; i < pkgs.length; i++) {
var cls = (pkgs[i].classification || 'unknown').toLowerCase().replace(/_/g, '-');
if (cls === 'copyleftweak') cls = 'copyleft-weak';
if (cls === 'copyleftstrong') cls = 'copyleft-strong';
if (counts[cls] !== undefined) counts[cls]++;
else counts.unknown++;
}
var statsHtml = '';
var classLabels = [
['permissive', 'Permissive', 'var(--green)'],
['copyleft-weak', 'Copyleft (weak)', 'var(--yellow)'],
['copyleft-strong', 'Copyleft (strong)', 'var(--accent)'],
['proprietary', 'Proprietary', 'var(--accent)'],
['unknown', 'Unknown', 'var(--muted)']
];
for (var j = 0; j < classLabels.length; j++) {
var key = classLabels[j][0], label = classLabels[j][1], color = classLabels[j][2];
statsHtml += '<div class="scan-stat-row">' +
'<span style="color:var(--muted);">' + label + '</span>' +
'<span style="color:' + color + ';font-weight:700;">' + counts[key] + '</span>' +
'</div>';
}
document.getElementById('scan-stats').innerHTML = statsHtml;
var total = pkgs.length || 1;
var barHtml = '';
var barColors = { permissive: 'var(--green)', 'copyleft-weak': 'var(--yellow)', 'copyleft-strong': 'var(--accent)', proprietary: 'var(--accent)', unknown: 'var(--muted)' };
for (var k = 0; k < classLabels.length; k++) {
var bk = classLabels[k][0];
if (counts[bk] > 0) {
barHtml += '<div style="width:' + (counts[bk] / total * 100) + '%;background:' + barColors[bk] + ';"></div>';
}
}
document.getElementById('license-bar').innerHTML = barHtml;
renderScanTable();
}
function renderScanTable() {
var pkgs = currentScanPackages;
var limit = scanShowAll ? pkgs.length : Math.min(pkgs.length, SCAN_PAGE_SIZE);
document.getElementById('scan-results-empty').style.display = 'none';
var html = '<table class="scan-table"><thead><tr>' +
'<th>Package</th><th>Version</th><th>Ecosystem</th><th>License</th><th>Class</th>' +
'</tr></thead><tbody>';
for (var i = 0; i < limit; i++) {
var p = pkgs[i];
var cls = (p.classification || 'unknown').toLowerCase().replace(/_/g, '-');
if (cls === 'copyleftweak') cls = 'copyleft-weak';
if (cls === 'copyleftstrong') cls = 'copyleft-strong';
html += '<tr>' +
'<td>' + escapeHtml(p.name || '') + '</td>' +
'<td style="color:var(--muted);">' + escapeHtml(p.version || '') + '</td>' +
'<td><span class="eco-badge">' + escapeHtml(p.ecosystem || '') + '</span></td>' +
'<td class="license-cell">' + escapeHtml(p.spdx_license || p.raw_license || '—') + '</td>' +
'<td><span class="class-' + cls + '">' + escapeHtml(cls) + '</span></td>' +
'</tr>';
}
html += '</tbody></table>';
if (!scanShowAll && pkgs.length > SCAN_PAGE_SIZE) {
html += '<div class="scan-more" id="scan-show-more">... ' + (pkgs.length - SCAN_PAGE_SIZE) + ' more packages (click to expand)</div>';
}
document.getElementById('scan-results-table').innerHTML = html;
if (!scanShowAll && pkgs.length > SCAN_PAGE_SIZE) {
document.getElementById('scan-show-more').addEventListener('click', function() {
scanShowAll = true;
renderScanTable();
});
}
}
function clearScanHistory() {
if (!confirm('Delete all scan history?')) return;
fetch('/api/scans', { method: 'DELETE' }).then(function(r) { return r.json(); }).then(function(data) {
document.getElementById('scan-history-empty').style.display = '';
document.getElementById('scan-history-table').innerHTML = '';
document.getElementById('scan-summary').style.display = 'none';
document.getElementById('scan-results-empty').style.display = '';
document.getElementById('scan-results-table').innerHTML = '';
}).catch(function() {});
}
function loadScanHistory() {
fetch('/api/scans').then(function(r) { return r.json(); }).then(function(data) {
var scans = data.scans || data || [];
if (!Array.isArray(scans)) scans = [];
if (scans.length === 0) {
document.getElementById('scan-history-empty').style.display = '';
document.getElementById('scan-history-table').innerHTML = '';
return;
}
document.getElementById('scan-history-empty').style.display = 'none';
var html = '<table class="history-table"><thead><tr>' +
'<th>Path</th><th>Packages</th><th>Scanned</th><th></th>' +
'</tr></thead><tbody>';
for (var i = 0; i < scans.length; i++) {
var s = scans[i];
var count = s.package_count !== undefined ? s.package_count : (s.packages ? s.packages.length : '?');
var scannedAt = s.scanned_at ? timeAgo(s.scanned_at) : '—';
html += '<tr>' +
'<td>' + escapeHtml(s.path || '') + '</td>' +
'<td style="color:var(--muted);">' + escapeHtml(String(count)) + '</td>' +
'<td style="color:var(--muted);">' + escapeHtml(scannedAt) + '</td>' +
'<td><span class="view-link" data-scan-id="' + escapeHtml(s.id || '') + '">view \u2192</span></td>' +
'</tr>';
}
html += '</tbody></table>';
document.getElementById('scan-history-table').innerHTML = html;
document.querySelectorAll('.view-link[data-scan-id]').forEach(function(el) {
el.addEventListener('click', function() {
var id = el.getAttribute('data-scan-id');
loadScanById(id);
});
});
}).catch(function() {
});
}
function loadScanById(id) {
setStatus('Loading scan...', 'busy');
fetch('/api/scans/' + encodeURIComponent(id)).then(function(r) {
if (!r.ok) throw new Error('not found');
return r.json();
}).then(function(result) {
displayScanResult(result);
setStatus('Scan loaded.', 'ok');
}).catch(function(err) {
showScanError('Failed to load scan: ' + err.message);
setStatus('Failed to load scan.', 'error');
});
}
</script>
</body>
</html>