tunnel/electron-agent/window.html

398 lines
12 KiB
HTML
Raw Normal View History

2026-07-16 12:17:00 +00:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tunnel Agent</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f0f0f;
color: #d8d8d8;
font-size: 13px;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
user-select: none;
}
/* ── Header ── */
.header {
display: flex;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid #1e1e1e;
background: #111;
flex-shrink: 0;
}
.header-title { flex: 1; font-size: 13px; font-weight: 600; color: #ccc; }
.icon-btn {
background: none;
border: none;
color: #555;
cursor: pointer;
font-size: 15px;
padding: 2px 6px;
border-radius: 4px;
line-height: 1;
}
.icon-btn:hover { color: #ccc; background: #1e1e1e; }
/* ── Tunnel list ── */
.tunnels {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #444;
font-size: 12px;
padding: 24px;
text-align: center;
}
.tunnel-item {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 11px;
border-radius: 7px;
background: #161616;
border: 1px solid #1e1e1e;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background: #2a2a2a;
transition: background 0.25s, box-shadow 0.25s;
}
.dot.active {
background: #22c55e;
box-shadow: 0 0 5px #22c55e99;
}
.t-info { flex: 1; min-width: 0; }
.t-name { font-weight: 600; color: #ccc; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.t-meta { font-size: 11px; color: #555; margin-top: 2px; }
.t-btn {
flex-shrink: 0;
padding: 3px 9px;
border-radius: 5px;
cursor: pointer;
font-size: 11px;
font-weight: 600;
border: 1px solid;
transition: background 0.15s;
}
.t-btn.conn { background: #0e2a52; border-color: #1a5fd4; color: #7aaeff; }
.t-btn.conn:hover { background: #1a3a70; }
.t-btn.disc { background: #3a1212; border-color: #802020; color: #f88; }
.t-btn.disc:hover { background: #4a1a1a; }
.del-btn {
flex-shrink: 0;
padding: 2px 5px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
border: 1px solid #232323;
background: none;
color: #444;
margin-left: 2px;
transition: color 0.15s, border-color 0.15s;
}
.del-btn:hover { color: #e05555; border-color: #e05555; }
/* ── Footer ── */
.footer {
flex-shrink: 0;
padding: 8px;
border-top: 1px solid #1a1a1a;
}
.add-btn {
width: 100%;
padding: 7px;
background: #141414;
border: 1px dashed #272727;
color: #555;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
transition: border-color 0.15s, color 0.15s;
}
.add-btn:hover { border-color: #444; color: #888; }
/* ── Add form ── */
.add-form {
flex-shrink: 0;
padding: 12px;
border-top: 1px solid #1a1a1a;
background: #0d0d0d;
display: flex;
flex-direction: column;
gap: 7px;
}
.f-row { display: flex; flex-direction: column; gap: 3px; }
.f-label {
font-size: 10px;
font-weight: 700;
color: #555;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.f-input {
background: #181818;
border: 1px solid #252525;
border-radius: 5px;
color: #ddd;
padding: 5px 8px;
font-size: 12px;
outline: none;
font-family: inherit;
}
.f-input:focus { border-color: #2a4a8a; }
select.f-input { cursor: pointer; appearance: none; }
.f-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.f-actions { display: flex; gap: 6px; margin-top: 2px; }
.f-submit {
flex: 1;
padding: 6px;
background: #1a4a8a;
border: none;
border-radius: 5px;
color: #aacfff;
font-weight: 600;
cursor: pointer;
font-size: 12px;
}
.f-submit:hover { background: #1a5fd4; }
.f-cancel {
padding: 6px 12px;
background: #181818;
border: 1px solid #252525;
border-radius: 5px;
color: #555;
cursor: pointer;
font-size: 12px;
}
.f-cancel:hover { color: #888; }
.form-error { font-size: 11px; color: #e05555; min-height: 1em; }
</style>
</head>
<body>
<div class="header">
<span class="header-title">Tunnel Agent</span>
<button class="icon-btn" id="refresh-btn" title="Refresh list"></button>
</div>
<div class="tunnels" id="tunnel-list">
<div class="empty">Loading…</div>
</div>
<div class="footer" id="footer">
<button class="add-btn" id="show-add-btn">+ Add Tunnel</button>
</div>
<div class="add-form" id="add-form" style="display:none">
<div class="f-row">
<span class="f-label">Name</span>
<input class="f-input" id="f-name" placeholder="My LLM Server">
</div>
<div class="f-grid">
<div class="f-row">
<span class="f-label">Purpose</span>
<select class="f-input" id="f-purpose">
<option value="llm-openai-compatible">LLM (OpenAI compat.)</option>
<option value="stable-diffusion">Stable Diffusion</option>
<option value="general">General</option>
</select>
</div>
<div class="f-row">
<span class="f-label">Local Port</span>
<input class="f-input" id="f-port" type="number" placeholder="11434" min="1" max="65535">
</div>
</div>
<div class="f-row">
<span class="f-label">Description (optional)</span>
<input class="f-input" id="f-desc" placeholder="e.g. Ollama on desktop">
</div>
<div class="f-row">
<span class="f-label">Access</span>
<select class="f-input" id="f-access">
<option value="private">Private — only me</option>
<option value="public">Public — all users</option>
</select>
</div>
<p class="form-error" id="form-error"></p>
<div class="f-actions">
<button class="f-submit" id="create-btn">Create Tunnel</button>
<button class="f-cancel" id="cancel-btn">Cancel</button>
</div>
</div>
<script>
const api = window.tunnelAPI;
let tunnels = [];
// ── Helpers ──────────────────────────────────────────────
function esc( s )
{
return String( s )
.replace( /&/g, '&amp;' )
.replace( /</g, '&lt;' )
.replace( />/g, '&gt;' );
}
// ── Render ───────────────────────────────────────────────
function render()
{
const list = document.getElementById( 'tunnel-list' );
if ( !tunnels.length )
{
list.innerHTML = '<div class="empty">No tunnels yet.<br>Add one below.</div>';
return;
}
list.innerHTML = tunnels.map( t => `
<div class="tunnel-item">
<div class="dot ${ t.agentActive ? 'active' : '' }"></div>
<div class="t-info">
<div class="t-name">${ esc( t.name ) }</div>
<div class="t-meta">${ esc( t.purpose ) } · :${ t.localPort }</div>
</div>
${ t.agentActive
? `<button class="t-btn disc" data-action="disconnect" data-id="${ t.id }">Disconnect</button>`
: `<button class="t-btn conn" data-action="connect" data-id="${ t.id }">Connect</button>`
}
<button class="del-btn" data-action="delete" data-id="${ t.id }" title="Delete tunnel"></button>
</div>
` ).join( '' );
}
// ── Load ─────────────────────────────────────────────────
async function refresh()
{
try
{
tunnels = await api.listTunnels();
render();
}
catch
{
document.getElementById( 'tunnel-list' ).innerHTML =
'<div class="empty">Could not reach tunnel server.</div>';
}
}
// ── Actions ──────────────────────────────────────────────
document.getElementById( 'tunnel-list' ).addEventListener( 'click', async e =>
{
const btn = e.target.closest( '[data-action]' );
if ( !btn ) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
if ( action === 'connect' )
{
btn.disabled = true;
btn.textContent = '…';
await api.connectTunnel( id );
}
else if ( action === 'disconnect' )
{
await api.disconnectTunnel( id );
}
else if ( action === 'delete' )
{
const t = tunnels.find( t => t.id === id );
if ( !confirm( `Delete "${ t?.name ?? id }"? This cannot be undone.` ) ) return;
await api.deleteTunnel( id );
await refresh();
}
} );
// Status pushed from main process
api.onStatusChange( ( id, active ) =>
{
const t = tunnels.find( t => t.id === id );
if ( t ) { t.agentActive = active; render(); }
} );
// ── Add form ─────────────────────────────────────────────
document.getElementById( 'show-add-btn' ).addEventListener( 'click', () =>
{
document.getElementById( 'add-form' ).style.display = '';
document.getElementById( 'footer' ).style.display = 'none';
} );
function hideForm()
{
document.getElementById( 'add-form' ).style.display = 'none';
document.getElementById( 'footer' ).style.display = '';
document.getElementById( 'form-error' ).textContent = '';
}
document.getElementById( 'cancel-btn' ).addEventListener( 'click', hideForm );
document.getElementById( 'create-btn' ).addEventListener( 'click', async () =>
{
const name = document.getElementById( 'f-name' ).value.trim();
const port = parseInt( document.getElementById( 'f-port' ).value );
const purpose = document.getElementById( 'f-purpose' ).value;
const desc = document.getElementById( 'f-desc' ).value.trim();
const access = document.getElementById( 'f-access' ).value;
if ( !name ) { document.getElementById( 'form-error' ).textContent = 'Name is required.'; return; }
if ( !port || port < 1 || port > 65535 )
{
document.getElementById( 'form-error' ).textContent = 'Enter a valid local port (165535).';
return;
}
try
{
await api.addTunnel( { name, localPort: port, purpose, description: desc, access } );
document.getElementById( 'f-name' ).value = '';
document.getElementById( 'f-port' ).value = '';
document.getElementById( 'f-desc' ).value = '';
hideForm();
await refresh();
}
catch ( err )
{
document.getElementById( 'form-error' ).textContent = String( err );
}
} );
document.getElementById( 'refresh-btn' ).addEventListener( 'click', refresh );
refresh();
</script>
</body>
</html>