tunnel/electron-agent/window.html

398 lines
12 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>