<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>llmposter debug UI</title>
<style>
:root {
--bg: #1a1b26; --surface: #24283b; --surface2: #2f334d;
--text: #c0caf5; --dim: #565f89; --accent: #7aa2f7;
--green: #9ece6a; --red: #f7768e; --yellow: #e0af68; --orange: #ff9e64;
--border: #3b4261; --mono: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: var(--mono); font-size: 13px; background: var(--bg); color: var(--text); height: 100vh; display: flex; flex-direction: column; }
header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 10px 16px; display: flex; align-items: center; gap: 16px; }
header h1 { font-size: 15px; font-weight: 600; color: var(--accent); }
header .stats { color: var(--dim); font-size: 12px; }
.tabs { display: flex; gap: 2px; }
.tab { padding: 6px 14px; background: transparent; border: none; color: var(--dim); cursor: pointer; font-family: var(--mono); font-size: 12px; border-radius: 4px 4px 0 0; }
.tab.active { background: var(--surface2); color: var(--text); }
.tab:hover { color: var(--text); }
main { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
.panel { display: none; flex: 1; overflow: hidden; flex-direction: column; }
.panel.active { display: flex; }
.request-table-wrap { flex: 1; overflow-y: auto; }
table { width: 100%; border-collapse: collapse; }
thead { position: sticky; top: 0; background: var(--surface); z-index: 1; }
th { text-align: left; padding: 8px 12px; color: var(--dim); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border); }
td { padding: 6px 12px; border-bottom: 1px solid var(--border); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; }
tr.req-row { cursor: pointer; }
tr.req-row:hover { background: var(--surface2); }
tr.selected { background: var(--surface2); }
.badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 11px; font-weight: 500; }
.badge-2xx { background: rgba(158,206,106,0.15); color: var(--green); }
.badge-3xx { background: rgba(224,175,104,0.15); color: var(--yellow); }
.badge-4xx { background: rgba(247,118,142,0.15); color: var(--red); }
.badge-5xx { background: rgba(247,118,142,0.15); color: var(--red); }
.provider { font-size: 11px; }
.provider-openai { color: var(--green); }
.provider-anthropic { color: var(--orange); }
.provider-gemini { color: var(--accent); }
.provider-responses { color: var(--yellow); }
.provider-unknown { color: var(--dim); }
.detail-wrap { display: flex; border-top: 1px solid var(--border); height: 0; transition: height 0.2s; overflow: hidden; }
.detail-wrap.open { height: 40vh; }
.detail-pane { flex: 1; overflow-y: auto; padding: 12px; }
.detail-pane h3 { font-size: 11px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.detail-pane pre { font-size: 12px; white-space: pre-wrap; word-break: break-all; color: var(--text); line-height: 1.5; }
.detail-pane + .detail-pane { border-left: 1px solid var(--border); }
.debugger { padding: 16px; display: flex; flex-direction: column; gap: 12px; flex: 1; overflow-y: auto; }
.debugger-form { display: flex; gap: 12px; align-items: flex-start; }
.debugger-form select, .debugger-form button { font-family: var(--mono); font-size: 12px; padding: 6px 10px; background: var(--surface2); color: var(--text); border: 1px solid var(--border); border-radius: 4px; }
.debugger-form button { cursor: pointer; background: var(--accent); color: var(--bg); border: none; font-weight: 600; }
.debugger-form button:hover { opacity: 0.9; }
.debugger-textarea { flex: 1; min-height: 120px; font-family: var(--mono); font-size: 12px; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 10px; resize: vertical; }
.debug-results { display: flex; flex-direction: column; gap: 8px; }
.fixture-eval { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; padding: 10px; }
.fixture-eval.matched { border-color: var(--green); }
.fixture-eval-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.fixture-eval-header .label { font-weight: 600; }
.fixture-eval-header .result { font-size: 11px; }
.pass-text { color: var(--green); }
.fail-text { color: var(--red); }
.field-check { font-size: 12px; padding: 2px 0; display: flex; gap: 8px; }
.field-check .icon { width: 14px; text-align: center; }
.field-check .field-name { color: var(--dim); min-width: 120px; }
.empty { color: var(--dim); text-align: center; padding: 40px; }
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); display: inline-block; animation: pulse 2s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.live-dot.disconnected { background: var(--red); animation: none; }
.live-dot.lagged { background: var(--yellow); }
</style>
</head>
<body>
<header>
<h1>llmposter</h1>
<div class="tabs" role="tablist">
<button class="tab active" data-panel="inspector" id="tab-inspector" role="tab" aria-selected="true" aria-controls="panel-inspector">Request Inspector</button>
<button class="tab" data-panel="debugger" id="tab-debugger" role="tab" aria-selected="false" aria-controls="panel-debugger">Match Debugger</button>
</div>
<div style="flex:1"></div>
<div class="stats">
<span class="live-dot" id="live-dot"></span>
<span id="req-count">0</span> requests
</div>
</header>
<main>
<div class="panel active" id="panel-inspector" role="tabpanel">
<div class="request-table-wrap">
<table>
<thead>
<tr>
<th style="width:90px">Time</th>
<th style="width:90px">Provider</th>
<th>Path</th>
<th style="width:160px">Outcome</th>
<th style="width:70px">Status</th>
</tr>
</thead>
<tbody id="request-body"></tbody>
</table>
<div class="empty" id="empty-msg">No requests yet. Send a request to the mock server to see it here.</div>
</div>
<div class="detail-wrap" id="detail-wrap">
<div class="detail-pane">
<h3>Request Body</h3>
<pre id="detail-request"></pre>
</div>
<div class="detail-pane">
<h3>Request Info</h3>
<pre id="detail-fixture"></pre>
</div>
</div>
</div>
<div class="panel" id="panel-debugger" role="tabpanel">
<div class="debugger">
<div class="debugger-form">
<select id="debug-provider">
<option value="openai">OpenAI Chat</option>
<option value="anthropic">Anthropic</option>
<option value="gemini">Gemini</option>
<option value="responses">Responses API</option>
</select>
<button id="debug-btn">Evaluate</button>
</div>
<textarea class="debugger-textarea" id="debug-body" placeholder='Paste a request body JSON here, e.g.:
{
"model": "gpt-4",
"messages": [{"role": "user", "content": "hello"}]
}'></textarea>
<div class="debug-results" id="debug-results"></div>
</div>
</div>
</main>
<script>
(function() {
"use strict";
var MAX_REQUESTS = 1000;
var tbody = document.getElementById('request-body');
var emptyMsg = document.getElementById('empty-msg');
var detailWrap = document.getElementById('detail-wrap');
var detailReq = document.getElementById('detail-request');
var detailFixture = document.getElementById('detail-fixture');
var reqCountEl = document.getElementById('req-count');
var liveDot = document.getElementById('live-dot');
var requests = [];
var selectedId = null;
document.querySelectorAll('.tab').forEach(function(tab) {
tab.addEventListener('click', function() {
document.querySelectorAll('.tab').forEach(function(t) {
t.classList.remove('active');
t.setAttribute('aria-selected', 'false');
});
document.querySelectorAll('.panel').forEach(function(p) { p.classList.remove('active'); });
tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
document.getElementById('panel-' + tab.dataset.panel).classList.add('active');
});
});
function badgeClass(code) {
if (code < 300) return 'badge-2xx';
if (code < 400) return 'badge-3xx';
if (code < 500) return 'badge-4xx';
return 'badge-5xx';
}
function providerClass(p) {
if (!p) return 'provider-unknown';
return 'provider-' + p.toLowerCase();
}
function providerName(p) {
if (!p) return '-';
if (p === 'responses') return 'Resp';
return p.charAt(0).toUpperCase() + p.slice(1);
}
function formatTime(ms) {
var d = new Date(ms);
var h = String(d.getHours()).padStart(2, '0');
var m = String(d.getMinutes()).padStart(2, '0');
var s = String(d.getSeconds()).padStart(2, '0');
var ms2 = String(d.getMilliseconds()).padStart(3, '0');
return h + ':' + m + ':' + s + '.' + ms2;
}
var outcomeMap = {
matched: 'Matched', no_match: 'No Match', bad_request: 'Bad Request',
auth_rejected: 'Auth 401', code_endpoint: '/code'
};
function outcomeLabel(o) { return outcomeMap[o] || o; }
function addRow(req) {
emptyMsg.style.display = 'none';
var tr = document.createElement('tr');
tr.className = 'req-row';
tr.dataset.id = req.id;
var tdTime = document.createElement('td');
tdTime.textContent = formatTime(req.timestamp_ms);
tr.appendChild(tdTime);
var tdProvider = document.createElement('td');
var provSpan = document.createElement('span');
provSpan.className = 'provider ' + providerClass(req.provider);
provSpan.textContent = providerName(req.provider);
tdProvider.appendChild(provSpan);
tr.appendChild(tdProvider);
var tdPath = document.createElement('td');
tdPath.textContent = req.path;
tdPath.title = req.path;
tr.appendChild(tdPath);
var tdOutcome = document.createElement('td');
tdOutcome.textContent = outcomeLabel(req.outcome);
if (req.matched_scenario) {
var scenSpan = document.createElement('span');
scenSpan.style.color = 'var(--dim)';
scenSpan.textContent = ' (' + req.matched_scenario + ')';
tdOutcome.appendChild(scenSpan);
}
tr.appendChild(tdOutcome);
var tdStatus = document.createElement('td');
var badge = document.createElement('span');
badge.className = 'badge ' + badgeClass(req.status_code);
badge.textContent = req.status_code;
tdStatus.appendChild(badge);
tr.appendChild(tdStatus);
tr.addEventListener('click', function() { selectRow(req); });
tr.setAttribute('tabindex', '0');
tr.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectRow(req); }
});
tbody.prepend(tr);
reqCountEl.textContent = requests.length;
}
function trimRequests() {
while (requests.length > MAX_REQUESTS) {
requests.shift();
if (tbody.lastChild) tbody.removeChild(tbody.lastChild);
}
reqCountEl.textContent = requests.length;
}
function selectRow(req) {
selectedId = req.id;
document.querySelectorAll('#request-body tr').forEach(function(r) {
r.classList.toggle('selected', r.dataset.id == req.id);
});
detailWrap.classList.add('open');
try {
detailReq.textContent = JSON.stringify(JSON.parse(req.request_body), null, 2);
} catch(e) {
detailReq.textContent = req.request_body || '(empty)';
}
detailFixture.textContent =
'Outcome: ' + outcomeLabel(req.outcome) +
'\nProvider: ' + (req.provider || '-') +
'\nPath: ' + req.path +
'\nMethod: ' + req.method +
(req.matched_scenario ? '\nScenario: ' + req.matched_scenario : '');
}
function connectSSE() {
var es = new EventSource('/ui/events');
es.onopen = function() { liveDot.classList.remove('disconnected'); liveDot.classList.remove('lagged'); };
es.onmessage = function(e) {
try {
var req = JSON.parse(e.data);
requests.push(req);
addRow(req);
trimRequests();
} catch(err) {}
};
es.addEventListener('lagged', function(e) {
liveDot.classList.add('lagged');
setTimeout(function() { liveDot.classList.remove('lagged'); }, 3000);
});
es.onerror = function() {
liveDot.classList.add('disconnected');
es.close();
setTimeout(connectSSE, 2000);
};
}
fetch('/ui/meta').then(function(r) { return r.json(); }).then(function(meta) {
if (meta.capture_capacity != null && meta.capture_capacity > 0) {
MAX_REQUESTS = meta.capture_capacity;
}
}).catch(function() {}).finally(function() {
fetch('/ui/requests').then(function(r) { return r.json(); }).then(function(data) {
requests = data;
data.forEach(addRow);
trimRequests();
connectSSE();
}).catch(function() { connectSSE(); });
});
document.getElementById('debug-btn').addEventListener('click', function() {
var provider = document.getElementById('debug-provider').value;
var body = document.getElementById('debug-body').value;
var resultsDiv = document.getElementById('debug-results');
try { JSON.parse(body); } catch(e) {
resultsDiv.textContent = '';
var errDiv = document.createElement('div');
errDiv.className = 'empty';
errDiv.style.color = 'var(--red)';
errDiv.textContent = 'Invalid JSON: ' + e.message;
resultsDiv.appendChild(errDiv);
return;
}
fetch('/ui/debug', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: provider, body: body })
}).then(function(r) { return r.json(); }).then(function(result) {
renderDebugResults(result, resultsDiv);
}).catch(function(e) {
resultsDiv.textContent = '';
var errDiv = document.createElement('div');
errDiv.className = 'empty';
errDiv.style.color = 'var(--red)';
errDiv.textContent = 'Error: ' + e.message;
resultsDiv.appendChild(errDiv);
});
});
function renderDebugResults(result, container) {
container.textContent = '';
if (!result.fixtures || result.fixtures.length === 0) {
var emptyDiv = document.createElement('div');
emptyDiv.className = 'empty';
emptyDiv.textContent = 'No fixtures loaded.';
container.appendChild(emptyDiv);
return;
}
result.fixtures.forEach(function(f, i) {
var isMatch = result.matched_index === i;
var div = document.createElement('div');
div.className = 'fixture-eval' + (isMatch ? ' matched' : '');
var hdr = document.createElement('div');
hdr.className = 'fixture-eval-header';
var label = document.createElement('span');
label.className = 'label';
label.textContent = '#' + f.index + ' ' + f.label +
(f.catch_all ? ' [catch-all]' : '') +
(f.priority != null ? ' (pri: ' + f.priority + ')' : '');
hdr.appendChild(label);
var resultSpan = document.createElement('span');
resultSpan.className = 'result ' + (f.passed ? 'pass-text' : 'fail-text');
resultSpan.textContent = isMatch ? 'MATCHED' : f.passed ? 'PASS (shadowed)' : 'FAIL';
hdr.appendChild(resultSpan);
div.appendChild(hdr);
f.checks.forEach(function(c) {
var row = document.createElement('div');
row.className = 'field-check';
var icon = document.createElement('span');
icon.className = 'icon ' + (c.passed ? 'pass-text' : 'fail-text');
icon.textContent = c.passed ? '\u2713' : '\u2717';
row.appendChild(icon);
var fname = document.createElement('span');
fname.className = 'field-name';
fname.textContent = c.field;
row.appendChild(fname);
if (!c.passed) {
var detail = document.createElement('span');
detail.textContent = 'expected: ' + c.expected + ', got: ' + (c.actual || '(missing)');
row.appendChild(detail);
}
div.appendChild(row);
});
container.appendChild(div);
});
}
})();
</script>
</body>
</html>