Files
svc-proxy/internal/httpsrv/static/index.html
T
claude-timemachine 41a7e39754
CI / validate (push) Successful in 7s
CI / docker (push) Failing after 5s
rename: bridges/valves → tunnels (one term across types + API + UI)
API shape:
  GET /api/connections → GET /api/tunnels
  body: {"connections": […]} → {"tunnels": […]}

Type rename (package stays "bridge" — internal):
  Valve         → Listener
  clientBridge  → tunnel
  ConnSnapshot  → TunnelSnapshot

Log messages mirror the new vocab ("listener open/close", "tunnel
open/idle evict/forward failed"). UI header is now "Active tunnels"
and the empty state reads "no active tunnels".

server-manager's dashboard polls /infra/svc-proxy/api/tunnels and
shows "N tunnels" on the svc-proxy infra card.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 18:48:17 +02:00

158 lines
6.4 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>svc-proxy</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #0e0e0e; color: #eee;
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 13px; line-height: 1.4; }
body { display: flex; flex-direction: column; }
header { padding: 8px 12px; background: #1a1a1a; border-bottom: 1px solid #333;
display: flex; justify-content: space-between; align-items: center; flex: 0 0 auto; }
header h1 { margin: 0; font-size: 14px; font-weight: 600; }
header h1 .meta { color: #888; font-weight: normal; margin-left: 10px; }
#status { font-size: 12px; color: #6f6; }
#status.disconnected { color: #f66; }
#status.connecting { color: #fc6; }
main { flex: 1; display: grid; grid-template-rows: 1fr 1fr; min-height: 0; }
section { padding: 12px 16px; overflow: auto; min-height: 0; }
section + section { border-top: 1px solid #333; }
h2 { margin: 0 0 8px; font-size: 12px; font-weight: 600; color: #888;
text-transform: uppercase; letter-spacing: 0.6px;
display: flex; justify-content: space-between; align-items: center; }
h2 .count { color: #888; font-weight: normal; margin-left: 6px; }
h2 button { background: transparent; color: #888; border: 1px solid #333;
padding: 2px 8px; cursor: pointer; font: inherit; font-size: 11px; border-radius: 3px; }
h2 button:hover { color: #eee; border-color: #6cf; }
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
th, td { text-align: left; padding: 4px 10px; border-bottom: 1px solid #1f1f1f; }
th { color: #888; font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
td.num { text-align: right; }
td.up { color: #6f6; }
td.down { color: #fc6; }
td.idle.stale { color: #fc6; }
td.idle.dead { color: #f66; }
.empty { color: #888; padding: 16px 0; }
pre.logbox { margin: 0; white-space: pre-wrap; word-break: break-word; }
.log-line { padding: 0; }
.log-line .ts { color: #888; margin-right: 8px; }
.log-line .lvl { margin-right: 6px; font-weight: 600; }
.log-line.lvl-DEBUG .lvl { color: #888; }
.log-line.lvl-INFO .lvl { color: #6cf; }
.log-line.lvl-WARN .lvl { color: #fc6; }
.log-line.lvl-ERROR .lvl { color: #f66; }
.log-line .attrs { color: #888; margin-left: 8px; }
</style>
</head>
<body>
<header>
<h1>svc-proxy <span class="meta" id="meta">— connecting…</span></h1>
<span id="status" class="connecting">log stream: connecting…</span>
</header>
<main>
<section>
<h2><span>Active tunnels <span class="count" id="conn-count"></span></span></h2>
<table>
<thead>
<tr>
<th>Server</th><th>Port</th><th>Client</th><th>Backend</th>
<th class="num">Up</th><th class="num">Down</th>
<th class="num">Idle</th><th class="num">Age</th>
</tr>
</thead>
<tbody id="conn-rows"></tbody>
</table>
<div class="empty" id="conn-empty">no active tunnels</div>
</section>
<section>
<h2><span>Logs</span><button onclick="document.getElementById('logbox').innerHTML=''">clear</button></h2>
<pre class="logbox" id="logbox"></pre>
</section>
</main>
<script>
const fmtBytes = n => {
if (n < 1024) return n + ' B';
if (n < 1024*1024) return (n/1024).toFixed(1) + ' KiB';
if (n < 1024*1024*1024) return (n/(1024*1024)).toFixed(2) + ' MiB';
return (n/(1024*1024*1024)).toFixed(2) + ' GiB';
};
const fmtAgo = secs => {
if (secs < 60) return secs.toFixed(0) + 's';
if (secs < 3600) return Math.floor(secs/60) + 'm' + Math.floor(secs%60) + 's';
return Math.floor(secs/3600) + 'h' + Math.floor((secs % 3600) / 60) + 'm';
};
async function refreshConnections() {
try {
const r = await fetch('./api/tunnels');
const j = await r.json();
const rows = document.getElementById('conn-rows');
const empty = document.getElementById('conn-empty');
const count = document.getElementById('conn-count');
rows.innerHTML = '';
const now = new Date(j.at).getTime();
if (!j.tunnels || j.tunnels.length === 0) {
empty.style.display = '';
count.textContent = '';
} else {
empty.style.display = 'none';
count.textContent = '(' + j.tunnels.length + ')';
for (const c of j.tunnels) {
const opened = new Date(c.opened_at).getTime();
const ageSecs = (now - opened) / 1000;
const idleCls = c.idle_seconds > 60 ? 'dead' : c.idle_seconds > 30 ? 'stale' : '';
const tr = document.createElement('tr');
tr.innerHTML =
'<td>' + c.server + '</td>' +
'<td>:' + c.port + '</td>' +
'<td>' + c.client + '</td>' +
'<td>' + c.backend + '</td>' +
'<td class="num up">↑ ' + fmtBytes(c.bytes_up) + '</td>' +
'<td class="num down">↓ ' + fmtBytes(c.bytes_down) + '</td>' +
'<td class="num idle ' + idleCls + '">' + fmtAgo(c.idle_seconds) + '</td>' +
'<td class="num">' + fmtAgo(ageSecs) + '</td>';
rows.appendChild(tr);
}
}
document.getElementById('meta').textContent = '— ' + j.tunnels.length + ' tunnels';
} catch (e) {
document.getElementById('meta').textContent = '— api error';
}
}
setInterval(refreshConnections, 1000);
refreshConnections();
function startLogStream() {
const status = document.getElementById('status');
const box = document.getElementById('logbox');
const es = new EventSource('./api/logs');
es.onopen = () => { status.textContent = 'log stream: live'; status.className = ''; };
es.onerror = () => { status.textContent = 'log stream: reconnecting…'; status.className = 'disconnected'; };
es.onmessage = ev => {
let e;
try { e = JSON.parse(ev.data); } catch { return; }
const ts = e.time ? e.time.split('T')[1].split('.')[0] : '';
const div = document.createElement('div');
div.className = 'log-line lvl-' + (e.level || 'INFO');
div.innerHTML =
'<span class="ts">' + ts + '</span>' +
'<span class="lvl">' + (e.level || 'INFO') + '</span>' +
(e.msg || '') +
(e.attrs ? '<span class="attrs">' + e.attrs + '</span>' : '');
box.appendChild(div);
const parent = box.parentElement;
if (parent.scrollHeight - parent.scrollTop - parent.clientHeight < 60) {
parent.scrollTop = parent.scrollHeight;
}
while (box.children.length > 1000) box.removeChild(box.firstChild);
};
}
startLogStream();
</script>
</body>
</html>