<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PHALUS</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--accent: #58a6ff;
--accent2: #3fb950;
--warn: #d29922;
--danger: #f85149;
--text: #e6edf3;
--muted: #8b949e;
--radius: 6px;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
min-height: 100vh;
}
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 24px;
height: 56px;
display: flex;
align-items: center;
gap: 12px;
}
header .logo {
font-size: 18px;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--accent);
}
header .tagline {
color: var(--muted);
font-size: 12px;
}
header .badge {
margin-left: auto;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 20px;
padding: 2px 10px;
font-size: 11px;
color: var(--muted);
}
main {
max-width: 960px;
margin: 32px auto;
padding: 0 24px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 700px) {
main { grid-template-columns: 1fr; }
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
}
.card h2 {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 14px;
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
label {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 4px;
}
textarea, input, select {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
padding: 8px 10px;
outline: none;
transition: border-color 0.15s;
font-family: inherit;
}
textarea:focus, input:focus, select:focus {
border-color: var(--accent);
}
textarea {
resize: vertical;
min-height: 140px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 12px;
}
select option { background: var(--surface); }
.field { margin-bottom: 14px; }
.dropzone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 16px;
text-align: center;
cursor: pointer;
color: var(--muted);
font-size: 12px;
transition: border-color 0.15s, background 0.15s;
margin-bottom: 8px;
}
.dropzone.drag-over {
border-color: var(--accent);
background: rgba(88, 166, 255, 0.05);
}
.dropzone span { display: block; margin-top: 4px; font-size: 11px; }
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
border: none;
border-radius: var(--radius);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn:not(:disabled):hover { opacity: 0.85; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-success { background: var(--accent2); color: #000; }
.btn-danger { background: var(--danger); color: #fff; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-row {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.progress-list {
list-style: none;
max-height: 220px;
overflow-y: auto;
font-size: 12px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
.progress-list li {
padding: 3px 0;
border-bottom: 1px solid var(--border);
color: var(--muted);
}
.progress-list li.ok { color: var(--accent2); }
.progress-list li.fail { color: var(--danger); }
.progress-list li.info { color: var(--accent); }
.progress-empty {
color: var(--muted);
font-size: 12px;
font-style: italic;
}
.progress-counter {
font-size: 12px;
color: var(--accent);
margin-bottom: 8px;
font-weight: 600;
}
.results-placeholder {
color: var(--muted);
font-size: 12px;
font-style: italic;
padding: 12px 0;
}
.result-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.result-card .pkg-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.result-card .pkg-name {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 13px;
font-weight: 600;
}
.result-card .pkg-meta {
font-size: 11px;
color: var(--muted);
}
.result-card .pkg-badge {
font-weight: 700;
font-size: 11px;
padding: 2px 10px;
border-radius: 12px;
text-transform: uppercase;
}
.result-card .pkg-badge.pass {
background: rgba(63, 185, 80, 0.15);
color: var(--accent2);
}
.result-card .pkg-badge.fail {
background: rgba(248, 81, 73, 0.15);
color: var(--danger);
}
.badge-pass { color: var(--accent2); font-weight: 700; font-size: 11px; }
.badge-fail { color: var(--danger); font-weight: 700; font-size: 11px; }
.download-banner {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 16px;
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;
}
.parsed-packages {
list-style: none;
margin-top: 8px;
}
.parsed-packages li {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.parsed-packages li:last-child { border-bottom: none; }
.parsed-packages .eco-badge {
background: rgba(88, 166, 255, 0.1);
color: var(--accent);
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 3px;
text-transform: uppercase;
}
.parsed-packages .pkg-name {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-weight: 600;
}
.parsed-packages .pkg-ver {
color: var(--muted);
}
.span-2 { grid-column: 1 / -1; }
#status-bar {
background: var(--surface);
border-top: 1px solid var(--border);
padding: 6px 24px;
font-size: 11px;
color: var(--muted);
position: fixed;
bottom: 0;
left: 0;
right: 0;
}
#status-bar .dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent2);
margin-right: 6px;
vertical-align: middle;
}
#status-bar .dot.idle { background: var(--muted); }
#status-bar .dot.busy { background: var(--warn); animation: pulse 1s infinite; }
#status-bar .dot.error { background: var(--danger); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
body { padding-bottom: 40px; }
</style>
</head>
<body>
<header>
<span class="logo">PHALUS</span>
<span class="tagline">Private Headless Automated License Uncoupling System</span>
<span class="badge" id="health-badge">checking…</span>
</header>
<main>
<div class="card">
<h2>Manifest Input</h2>
<div id="dropzone" class="dropzone" role="button" tabindex="0"
aria-label="Drop manifest file here or click to browse">
Drop manifest file here
<span>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-ghost" id="parse-btn" style="width:100%">Parse Manifest</button>
<div id="parse-result" style="margin-top:10px; font-size:12px; color:var(--muted);"></div>
</div>
<div class="card">
<h2>Run Options</h2>
<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="btn-row">
<button class="btn btn-success" id="start-btn">▶ Start</button>
<button class="btn btn-danger" id="stop-btn" disabled>■ Stop</button>
<button class="btn btn-ghost" id="clear-btn">Clear</button>
</div>
</div>
<div class="card">
<h2>Progress</h2>
<div class="progress-counter" id="progress-counter" style="display:none"></div>
<p class="progress-empty" id="progress-empty">No job running. Start a job to see progress here.</p>
<ul class="progress-list" id="progress-list" style="display:none"></ul>
</div>
<div class="card" id="results-card">
<h2>Results</h2>
<p class="results-placeholder" id="results-empty">Results will appear here after a job completes.</p>
<div id="results-list"></div>
</div>
</main>
<div id="status-bar">
<span class="dot idle" id="status-dot"></span>
<span id="status-text">Idle — connect PHALUS to an LLM provider to run jobs</span>
</div>
<script>
"use strict";
let currentJobId = null;
let totalPackages = 0;
let processedPackages = 0;
let packageStartTimes = {};
async function checkHealth() {
try {
const r = await fetch("/api/health");
if (r.ok) {
document.getElementById("health-badge").textContent = "API ok";
document.getElementById("health-badge").style.color = "var(--accent2)";
} else {
throw new Error("non-ok");
}
} catch {
document.getElementById("health-badge").textContent = "API unreachable";
document.getElementById("health-badge").style.color = "var(--danger)";
}
}
checkHealth();
const dropzone = document.getElementById("dropzone");
const fileInput = document.getElementById("file-input");
const textArea = document.getElementById("manifest-text");
dropzone.addEventListener("click", () => fileInput.click());
dropzone.addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") fileInput.click(); });
dropzone.addEventListener("dragover", e => { e.preventDefault(); dropzone.classList.add("drag-over"); });
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("drag-over"));
dropzone.addEventListener("drop", e => {
e.preventDefault();
dropzone.classList.remove("drag-over");
const file = e.dataTransfer.files[0];
if (file) readFile(file);
});
fileInput.addEventListener("change", () => {
if (fileInput.files[0]) readFile(fileInput.files[0]);
});
function readFile(file) {
const reader = new FileReader();
reader.onload = ev => { textArea.value = ev.target.result; };
reader.readAsText(file);
dropzone.textContent = file.name;
}
document.getElementById("parse-btn").addEventListener("click", async () => {
const body = textArea.value.trim();
if (!body) { setParseResult("Paste or drop a manifest first.", "warn"); return; }
try {
const r = await fetch("/api/manifest/parse", {
method: "POST",
headers: { "Content-Type": "text/plain" },
body,
});
const json = await r.json();
if (json.error) {
setParseResult("Error: " + json.error, "fail");
} else {
showParsedPackages(json);
}
} catch (err) {
setParseResult("Error: " + err.message, "fail");
}
});
function setParseResult(msg, kind) {
const el = document.getElementById("parse-result");
el.textContent = msg;
el.style.color = kind === "ok" ? "var(--accent2)" : kind === "fail" ? "var(--danger)" : "var(--warn)";
}
function showParsedPackages(manifest) {
const el = document.getElementById("parse-result");
el.style.color = "var(--accent2)";
const type = manifest.manifest_type || "unknown";
const pkgs = manifest.packages || [];
totalPackages = pkgs.length;
let html = '<div style="margin-bottom:6px;color:var(--accent2);font-weight:600;">' +
escapeHtml(String(pkgs.length)) + ' package' + (pkgs.length !== 1 ? 's' : '') +
' detected (' + escapeHtml(type) + ')</div>';
html += '<ul class="parsed-packages">';
for (const pkg of pkgs) {
const eco = pkg.ecosystem || "unknown";
const name = pkg.name || "?";
const ver = pkg.version_constraint || "*";
html += '<li>' +
'<span class="eco-badge">' + escapeHtml(eco) + '</span>' +
'<span class="pkg-name">' + escapeHtml(name) + '</span>' +
'<span class="pkg-ver">' + escapeHtml(ver) + '</span>' +
'</li>';
}
html += '</ul>';
el.innerHTML = html;
}
let jobRunning = false;
let eventSource = null;
function addProgress(msg, kind) {
const list = document.getElementById("progress-list");
const empty = document.getElementById("progress-empty");
empty.style.display = "none";
list.style.display = "";
const li = document.createElement("li");
li.textContent = msg;
if (kind) li.className = kind;
list.appendChild(li);
list.scrollTop = list.scrollHeight;
}
function updateProgressCounter(current, total) {
const counter = document.getElementById("progress-counter");
counter.style.display = "";
counter.textContent = "Processing " + current + " of " + total + "...";
}
function clearProgress() {
const list = document.getElementById("progress-list");
const empty = document.getElementById("progress-empty");
const counter = document.getElementById("progress-counter");
list.innerHTML = "";
list.style.display = "none";
empty.style.display = "";
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 escapeHtml(s) {
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
function addResultCard(pkg, version, pass, elapsed) {
document.getElementById("results-empty").style.display = "none";
document.getElementById("results-card").className = "card span-2";
const card = document.createElement("div");
card.className = "result-card";
const badgeClass = pass ? "pass" : "fail";
const badgeText = pass ? "PASS" : "FAIL";
const elapsedText = elapsed ? " — " + elapsed + "s" : "";
card.innerHTML =
'<div class="pkg-info">' +
'<span class="pkg-name">' + escapeHtml(pkg) + '@' + escapeHtml(version) + '</span>' +
'<span class="pkg-meta">Completed' + escapeHtml(elapsedText) + '</span>' +
'</div>' +
'<span class="pkg-badge ' + badgeClass + '">' + badgeText + '</span>';
document.getElementById("results-list").appendChild(card);
}
function setStatus(msg, kind) {
document.getElementById("status-text").textContent = msg;
const dot = document.getElementById("status-dot");
dot.className = "dot " + (kind || "idle");
}
document.getElementById("start-btn").addEventListener("click", startJob);
document.getElementById("stop-btn").addEventListener("click", stopJob);
async function startJob() {
const runOne = document.getElementById("run-one-input").value.trim();
const manifestContent = textArea.value.trim();
if (!runOne && !manifestContent) {
setStatus("Provide a package spec or manifest before starting.", "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");
let jobManifest = manifestContent;
if (runOne && !manifestContent) {
const parts = runOne.split("/");
if (parts.length === 2) {
const [eco, rest] = parts;
const [name, version] = (rest || "").split("@");
if (eco === "npm" && name && version) {
jobManifest = JSON.stringify({ dependencies: { [name]: version } });
}
}
if (!jobManifest) jobManifest = runOne;
}
addProgress("Submitting job to backend…", "info");
try {
const license = document.getElementById("license-select").value;
const r = await fetch("/api/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
manifest_content: jobManifest,
license: license,
isolation: "context",
}),
});
if (!r.ok) {
const err = await r.json().catch(() => ({ error: "unknown" }));
addProgress("Job creation failed: " + (err.error || r.statusText), "fail");
setStatus("Job failed.", "error");
jobRunning = false;
document.getElementById("start-btn").disabled = false;
document.getElementById("stop-btn").disabled = true;
return;
}
const { job_id } = await r.json();
currentJobId = job_id;
addProgress("Job created: " + job_id, "info");
eventSource = new EventSource("/api/jobs/" + job_id + "/stream");
eventSource.onmessage = function(ev) {
try {
const data = JSON.parse(ev.data);
if (data.PackageStarted) {
processedPackages++;
packageStartTimes[data.PackageStarted.name] = Date.now();
if (totalPackages > 0) {
updateProgressCounter(processedPackages, totalPackages);
}
addProgress("Started: " + data.PackageStarted.name, "info");
} else if (data.PhaseDone) {
addProgress(" " + data.PhaseDone.name + " → " + data.PhaseDone.phase, "");
} else if (data.PackageDone) {
const ok = data.PackageDone.success;
const pkgName = data.PackageDone.name;
let elapsed = null;
if (packageStartTimes[pkgName]) {
elapsed = ((Date.now() - packageStartTimes[pkgName]) / 1000).toFixed(1);
}
addProgress((ok ? "OK" : "FAIL") + " " + pkgName + (elapsed ? " (" + elapsed + "s)" : ""), ok ? "ok" : "fail");
addResultCard(pkgName, "latest", ok, elapsed);
} else if (data.JobDone != null) {
const total = data.JobDone.total;
const failed = data.JobDone.failed;
addProgress("Job done: " + total + " packages, " + failed + " failed", "ok");
const counter = document.getElementById("progress-counter");
counter.textContent = "Completed: " + total + " packages processed";
const resultsDiv = document.getElementById("results-list");
const summary = document.createElement("div");
summary.className = "download-banner";
const failText = failed > 0
? '<span style="color:var(--danger)">' + escapeHtml(String(failed)) + ' failed</span>'
: '<span style="color:var(--accent2)">0 failed</span>';
summary.innerHTML =
'<div class="summary-text">' +
escapeHtml(String(total)) + ' packages processed, ' + failText +
'</div>' +
'<a href="/api/jobs/' + escapeHtml(currentJobId) + '/download" ' +
'class="btn btn-success" style="text-decoration:none;display:inline-flex;">' +
'Download Output (.zip)' +
'</a>';
resultsDiv.insertBefore(summary, resultsDiv.firstChild);
setStatus("Idle — last job completed.", "idle");
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 (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", () => {
textArea.value = "";
document.getElementById("run-one-input").value = "";
document.getElementById("parse-result").textContent = "";
dropzone.innerHTML = 'Drop manifest file here<span>package.json · requirements.txt · Cargo.toml · go.mod</span>';
clearProgress();
clearResults();
currentJobId = null;
totalPackages = 0;
processedPackages = 0;
packageStartTimes = {};
setStatus("Idle", "idle");
});
</script>
</body>
</html>