terminal: cut over to multi-session lobby on terminal.viktorbarzin.me
Promotes the staged multi-session UX from term.viktorbarzin.me to the primary terminal.viktorbarzin.me hostname. `ttyd.service` on the DevVM moves to the same ExecStart that `ttyd-multi.service` was running: `/usr/local/bin/ttyd -W -a -t enableClipboard=true -I /usr/local/share/ttyd/index.html -p 7681 /usr/local/bin/tmux-attach.sh`. The lobby HTML supersedes the old per-user-attach index.html (ttyd-session.sh wrapper retired alongside). Terraform: retires the `terminal-multi` Service+Endpoints and the term.viktorbarzin.me ingress (Cloudflare DNS record for `term` is released by module deletion). The tmux-api Service+Endpoints stay, but its IngressRoute now matches terminal.viktorbarzin.me — same path-prefix specificity wins against the catch-all ingress. DevVM follow-up (applied manually as before — see files/devvm/README.md): restart ttyd to pick up the new unit, stop+disable ttyd-multi.service.
This commit is contained in:
parent
86a2c66c8e
commit
aff4f67671
5 changed files with 257 additions and 627 deletions
|
|
@ -1,17 +1,21 @@
|
|||
# DevVM terminal-multi files
|
||||
# DevVM terminal files
|
||||
|
||||
These files configure the multi-session terminal on the DevVM (`10.0.10.10`).
|
||||
They install **alongside** the existing `ttyd.service` (port 7681) and
|
||||
`ttyd-ro.service` (port 7682) — the existing units are **not** modified.
|
||||
These files configure ttyd + tmux-api on the DevVM (`10.0.10.10`). ttyd
|
||||
serves the multi-session lobby (and per-session attach via `?arg=<name>`)
|
||||
on port 7681; tmux-api is a small Go REST API on 7684 that powers the
|
||||
lobby's list/kill actions.
|
||||
|
||||
`terminal-ro.service` (port 7682, single read-only session) and
|
||||
`clipboard-upload` (port 7683) are unchanged by these files.
|
||||
|
||||
## Layout
|
||||
|
||||
| Source | Destination on DevVM |
|
||||
|--------|----------------------|
|
||||
| `tmux-attach.sh` | `/usr/local/bin/tmux-attach.sh` (chmod 0755) |
|
||||
| `ttyd-multi.service` | `/etc/systemd/system/ttyd-multi.service` |
|
||||
| `ttyd.service` | `/etc/systemd/system/ttyd.service` |
|
||||
| `tmux-api.service` | `/etc/systemd/system/tmux-api.service` |
|
||||
| `../index-multi.html` (one level up) | `/usr/local/share/ttyd/index-multi.html` |
|
||||
| `../index.html` (one level up) | `/usr/local/share/ttyd/index.html` |
|
||||
| `../../tmux-api/` binary, built `GOOS=linux GOARCH=amd64` | `/usr/local/bin/tmux-api` (chmod 0755) |
|
||||
|
||||
## Apply
|
||||
|
|
@ -25,43 +29,45 @@ DEVVM=10.0.10.10 # SSH config provides the user
|
|||
( cd infra/stacks/terminal/tmux-api && GOOS=linux GOARCH=amd64 go build -o /tmp/tmux-api . )
|
||||
|
||||
# 2. HTML page + wrapper script
|
||||
scp infra/stacks/terminal/files/index-multi.html $DEVVM:/tmp/index-multi.html
|
||||
scp infra/stacks/terminal/files/index.html $DEVVM:/tmp/index.html
|
||||
scp infra/stacks/terminal/files/devvm/tmux-attach.sh $DEVVM:/tmp/tmux-attach.sh
|
||||
ssh $DEVVM "sudo install -m 0644 /tmp/index-multi.html /usr/local/share/ttyd/index-multi.html && \
|
||||
sudo install -m 0755 /tmp/tmux-attach.sh /usr/local/bin/tmux-attach.sh && \
|
||||
rm /tmp/index-multi.html /tmp/tmux-attach.sh"
|
||||
ssh $DEVVM "sudo install -m 0644 /tmp/index.html /usr/local/share/ttyd/index.html && \
|
||||
sudo install -m 0755 /tmp/tmux-attach.sh /usr/local/bin/tmux-attach.sh && \
|
||||
rm /tmp/index.html /tmp/tmux-attach.sh"
|
||||
|
||||
# 3. tmux-api binary
|
||||
scp /tmp/tmux-api $DEVVM:/tmp/tmux-api
|
||||
ssh $DEVVM "sudo install -m 0755 /tmp/tmux-api /usr/local/bin/tmux-api && rm /tmp/tmux-api"
|
||||
|
||||
# 4. systemd units
|
||||
scp infra/stacks/terminal/files/devvm/ttyd-multi.service $DEVVM:/tmp/
|
||||
scp infra/stacks/terminal/files/devvm/tmux-api.service $DEVVM:/tmp/
|
||||
ssh $DEVVM "sudo mv /tmp/ttyd-multi.service /etc/systemd/system/ && \
|
||||
sudo mv /tmp/tmux-api.service /etc/systemd/system/ && \
|
||||
scp infra/stacks/terminal/files/devvm/ttyd.service $DEVVM:/tmp/
|
||||
scp infra/stacks/terminal/files/devvm/tmux-api.service $DEVVM:/tmp/
|
||||
ssh $DEVVM "sudo mv /tmp/ttyd.service /etc/systemd/system/ && \
|
||||
sudo mv /tmp/tmux-api.service /etc/systemd/system/ && \
|
||||
sudo systemctl daemon-reload && \
|
||||
sudo systemctl enable --now ttyd-multi tmux-api"
|
||||
sudo systemctl enable --now tmux-api && \
|
||||
sudo systemctl restart ttyd"
|
||||
|
||||
# 5. Sanity checks
|
||||
ssh $DEVVM "systemctl status ttyd-multi tmux-api --no-pager"
|
||||
ssh $DEVVM "systemctl status ttyd tmux-api --no-pager"
|
||||
ssh $DEVVM "curl -sf localhost:7684/sessions"
|
||||
ssh $DEVVM "curl -sf localhost:7685/ | head -5"
|
||||
ssh $DEVVM "systemctl is-active ttyd ttyd-ro" # existing units untouched
|
||||
ssh $DEVVM "curl -sf localhost:7681/ | head -5"
|
||||
ssh $DEVVM "systemctl is-active terminal-ro" # unrelated unit, unaffected
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **`User=wizard`** matches the existing `ttyd.service` so the new services
|
||||
share the same tmux server (one socket per Unix user). Sessions created
|
||||
via either `terminal.viktorbarzin.me` or `term.viktorbarzin.me` are
|
||||
cross-visible. This is intentional.
|
||||
- **ttyd version** is `1.7.7` on the DevVM — the `-a` flag (allow URL args
|
||||
→ argv) requires ≥ 1.7.
|
||||
- **`User=wizard`** — single Unix user owns the tmux server. Sessions are
|
||||
shared across every browser tab that attaches.
|
||||
- **ttyd version** must be ≥ 1.7 for the `-a` flag (allow URL args → argv).
|
||||
The DevVM currently has 1.7.7.
|
||||
- **Argv flow**: `?arg=foo` on the URL → ttyd appends `foo` as `$1` to
|
||||
`tmux-attach.sh` → the wrapper regex-validates and runs
|
||||
`tmux new-session -A -s "$name"`. ttyd uses argv (never a shell string),
|
||||
so there is no injection path.
|
||||
- **No external exposure of 7684/7685** — the DevVM is reachable only from
|
||||
- **No external exposure of 7684/7681** — the DevVM is reachable only from
|
||||
the cluster (`10.0.10.10` is on the internal VLAN). Authentik forward-auth
|
||||
on the ingress is the access gate.
|
||||
- **Cutover history** — `term.viktorbarzin.me` and `ttyd-multi.service`
|
||||
(port 7685) were the staging surface for this design. Both were retired
|
||||
in the same commit that promoted the multi-session config to port 7681.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
[Unit]
|
||||
Description=ttyd multi-session (port 7685) - tmux session lobby + per-session attach
|
||||
Description=ttyd Terminal Service (multi-session lobby + attach on port 7681)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/ttyd -W -a -t enableClipboard=true -I /usr/local/share/ttyd/index-multi.html -p 7685 /usr/local/bin/tmux-attach.sh
|
||||
ExecStart=/usr/local/bin/ttyd -W -a -t enableClipboard=true -I /usr/local/share/ttyd/index.html -p 7681 /usr/local/bin/tmux-attach.sh
|
||||
Restart=always
|
||||
User=wizard
|
||||
|
||||
|
|
@ -1,506 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Terminal</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #000; }
|
||||
#terminal { height: 100%; width: 100%; }
|
||||
.hidden { display: none !important; }
|
||||
#toast {
|
||||
position: fixed; top: 16px; right: 16px; z-index: 9999;
|
||||
background: #1a1a2e; color: #a29bfe; border: 1px solid #333;
|
||||
border-radius: 8px; padding: 10px 18px; font-family: monospace;
|
||||
font-size: 14px; opacity: 0; transition: opacity 0.3s;
|
||||
pointer-events: none; max-width: 500px; word-break: break-all;
|
||||
}
|
||||
#toast.visible { opacity: 1; }
|
||||
#toast.error { color: #e74c3c; border-color: #e74c3c; }
|
||||
#toast.success { color: #2ecc71; border-color: #2ecc71; }
|
||||
#paste-btn {
|
||||
position: fixed; bottom: 24px; right: 24px; z-index: 9999;
|
||||
width: 48px; height: 48px; border-radius: 12px;
|
||||
background: rgba(108, 92, 231, 0.6); border: 1px solid rgba(162, 155, 254, 0.4);
|
||||
color: #eee; font-size: 22px; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
touch-action: manipulation; -webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
#paste-btn:hover { background: rgba(108, 92, 231, 0.85); }
|
||||
#paste-btn:active { transform: scale(0.92); }
|
||||
#img-btn {
|
||||
position: fixed; bottom: 24px; right: 80px; z-index: 9999;
|
||||
width: 48px; height: 48px; border-radius: 12px;
|
||||
background: rgba(108, 92, 231, 0.6); border: 1px solid rgba(162, 155, 254, 0.4);
|
||||
color: #eee; font-size: 22px; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
touch-action: manipulation; -webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
#img-btn:hover { background: rgba(108, 92, 231, 0.85); }
|
||||
#img-btn:active { transform: scale(0.92); }
|
||||
#img-input { display: none; }
|
||||
|
||||
/* Lobby */
|
||||
#lobby {
|
||||
display: none; padding: 32px; height: 100vh; box-sizing: border-box;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace;
|
||||
color: #eee; background: #1a1a2e; overflow-y: auto;
|
||||
}
|
||||
#lobby.visible { display: block; }
|
||||
.lobby-header { font-size: 22px; color: #a29bfe; margin: 0 0 4px 0; }
|
||||
.lobby-sub { color: #888; font-size: 13px; margin: 0 0 24px 0; }
|
||||
.new-row { display: flex; gap: 8px; margin-bottom: 24px; max-width: 640px; }
|
||||
.new-row input {
|
||||
flex: 1; padding: 10px 14px; border-radius: 8px; border: 1px solid #333;
|
||||
background: #0f0f1f; color: #eee; font-family: inherit; font-size: 14px;
|
||||
}
|
||||
.new-row input:focus { outline: none; border-color: #a29bfe; }
|
||||
.new-row button {
|
||||
padding: 10px 18px; border-radius: 8px; border: 1px solid rgba(162, 155, 254, 0.4);
|
||||
background: rgba(108, 92, 231, 0.6); color: #eee; font-family: inherit;
|
||||
font-size: 14px; cursor: pointer;
|
||||
}
|
||||
.new-row button:hover { background: rgba(108, 92, 231, 0.85); }
|
||||
.session-list { display: grid; gap: 12px; max-width: 640px; }
|
||||
.session-card {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: #0f0f1f; border: 1px solid #2a2a3f; border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
.session-meta { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.session-name { font-size: 16px; font-weight: 600; color: #eee; }
|
||||
.session-detail { font-size: 12px; color: #888; }
|
||||
.session-detail.attached { color: #2ecc71; }
|
||||
.session-actions { display: flex; gap: 8px; }
|
||||
.session-actions button {
|
||||
padding: 8px 14px; border-radius: 6px; border: 1px solid #333;
|
||||
background: #1f1f33; color: #eee; font-family: inherit;
|
||||
font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.session-actions button.open { color: #a29bfe; border-color: rgba(162, 155, 254, 0.4); }
|
||||
.session-actions button.kill { color: #e74c3c; border-color: rgba(231, 76, 60, 0.4); }
|
||||
.session-actions button:hover { filter: brightness(1.3); }
|
||||
.empty { color: #888; font-style: italic; padding: 16px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="terminal"></div>
|
||||
<div id="lobby">
|
||||
<h1 class="lobby-header">tmux sessions</h1>
|
||||
<p class="lobby-sub">Pick an existing session or create a new one. Sessions persist after you close the tab.</p>
|
||||
<div class="new-row">
|
||||
<input id="new-name" type="text" placeholder="new session name (a-z, 0-9, _, -)" maxlength="32" autocomplete="off">
|
||||
<button id="new-btn">Create & Open</button>
|
||||
</div>
|
||||
<div id="session-list" class="session-list"></div>
|
||||
</div>
|
||||
<div id="toast"></div>
|
||||
<button id="img-btn" title="Upload image">📷</button>
|
||||
<button id="paste-btn" title="Paste from clipboard">📋</button>
|
||||
<input type="file" id="img-input" accept="image/*">
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
const NAME_RE = /^[a-zA-Z0-9_-]{1,32}$/;
|
||||
const SESSIONS_API = '/api/sessions/sessions';
|
||||
const params = new URLSearchParams(location.search);
|
||||
const rawArg = params.get('arg');
|
||||
const validArg = rawArg && NAME_RE.test(rawArg) ? rawArg : null;
|
||||
|
||||
function showToast(msg, type, duration) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = 'visible' + (type ? ' ' + type : '');
|
||||
clearTimeout(el._timer);
|
||||
el._timer = setTimeout(() => { el.className = ''; }, duration || 3000);
|
||||
}
|
||||
|
||||
function clearChildren(el) {
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
}
|
||||
|
||||
function emptyState(text) {
|
||||
const e = document.createElement('div');
|
||||
e.className = 'empty';
|
||||
e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
if (!validArg) {
|
||||
// ============================================================
|
||||
// LOBBY MODE — no valid ?arg=, show session picker
|
||||
// ============================================================
|
||||
document.getElementById('terminal').classList.add('hidden');
|
||||
document.getElementById('paste-btn').classList.add('hidden');
|
||||
document.getElementById('img-btn').classList.add('hidden');
|
||||
document.getElementById('lobby').classList.add('visible');
|
||||
document.title = 'tmux sessions';
|
||||
|
||||
const listEl = document.getElementById('session-list');
|
||||
const newNameEl = document.getElementById('new-name');
|
||||
const newBtnEl = document.getElementById('new-btn');
|
||||
|
||||
function relativeTime(epochSec) {
|
||||
if (!epochSec) return '';
|
||||
const diff = Math.floor(Date.now() / 1000) - epochSec;
|
||||
if (diff < 60) return diff + 's ago';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
function openSession(name) {
|
||||
if (!NAME_RE.test(name)) { showToast('Invalid name', 'error'); return; }
|
||||
location.search = '?arg=' + encodeURIComponent(name);
|
||||
}
|
||||
|
||||
async function killSession(name) {
|
||||
if (!NAME_RE.test(name)) { showToast('Invalid name', 'error'); return; }
|
||||
if (!confirm('Kill tmux session "' + name + '"? Any running processes inside it will be terminated.')) return;
|
||||
try {
|
||||
const resp = await fetch(SESSIONS_API + '/' + encodeURIComponent(name), {
|
||||
method: 'DELETE', credentials: 'same-origin'
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast('Killed ' + name, 'success');
|
||||
} else if (resp.status === 404) {
|
||||
showToast('Session not found', 'error');
|
||||
} else {
|
||||
showToast('Kill failed: HTTP ' + resp.status, 'error');
|
||||
}
|
||||
renderLobby();
|
||||
} catch (err) {
|
||||
showToast('Kill error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderCard(s) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'session-card';
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'session-meta';
|
||||
const name = document.createElement('div');
|
||||
name.className = 'session-name';
|
||||
name.textContent = s.name;
|
||||
meta.appendChild(name);
|
||||
const detail = document.createElement('div');
|
||||
detail.className = 'session-detail' + (s.attached > 0 ? ' attached' : '');
|
||||
const attachedText = s.attached > 0 ? (s.attached + ' attached') : 'idle';
|
||||
detail.textContent = attachedText + ' · last activity ' + relativeTime(s.lastActivity);
|
||||
meta.appendChild(detail);
|
||||
card.appendChild(meta);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'session-actions';
|
||||
const openBtn = document.createElement('button');
|
||||
openBtn.className = 'open';
|
||||
openBtn.textContent = 'Open';
|
||||
openBtn.onclick = () => openSession(s.name);
|
||||
actions.appendChild(openBtn);
|
||||
const killBtn = document.createElement('button');
|
||||
killBtn.className = 'kill';
|
||||
killBtn.textContent = 'Kill';
|
||||
killBtn.onclick = () => killSession(s.name);
|
||||
actions.appendChild(killBtn);
|
||||
card.appendChild(actions);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
async function renderLobby() {
|
||||
try {
|
||||
const resp = await fetch(SESSIONS_API, { credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
clearChildren(listEl);
|
||||
listEl.appendChild(emptyState('Failed to load sessions: HTTP ' + resp.status));
|
||||
return;
|
||||
}
|
||||
const sessions = await resp.json();
|
||||
clearChildren(listEl);
|
||||
if (sessions.length === 0) {
|
||||
listEl.appendChild(emptyState('No tmux sessions. Create one above.'));
|
||||
return;
|
||||
}
|
||||
sessions.sort((a, b) => b.lastActivity - a.lastActivity);
|
||||
for (const s of sessions) listEl.appendChild(renderCard(s));
|
||||
} catch (err) {
|
||||
clearChildren(listEl);
|
||||
listEl.appendChild(emptyState('Error: ' + err.message));
|
||||
}
|
||||
}
|
||||
|
||||
newBtnEl.addEventListener('click', () => {
|
||||
const name = newNameEl.value.trim();
|
||||
if (!NAME_RE.test(name)) {
|
||||
showToast('Name must match ^[a-zA-Z0-9_-]{1,32}$', 'error', 4000);
|
||||
return;
|
||||
}
|
||||
openSession(name);
|
||||
});
|
||||
newNameEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') newBtnEl.click();
|
||||
});
|
||||
|
||||
renderLobby();
|
||||
setInterval(renderLobby, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TERMINAL MODE — valid ?arg=<session>, attach via ttyd
|
||||
// ============================================================
|
||||
|
||||
// ttyd binary protocol
|
||||
const MSG_OUTPUT = 0x30; // '0' - terminal output
|
||||
const MSG_SET_PREFS = 0x31; // '1' - JSON preferences
|
||||
const MSG_SET_TITLE = 0x32; // '2' - window title
|
||||
const MSG_INPUT = '0'; // client → server: input data
|
||||
const MSG_RESIZE = '1'; // client → server: {columns, rows}
|
||||
|
||||
let ws = null;
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace",
|
||||
fontSize: 15,
|
||||
theme: {
|
||||
background: '#1a1a2e',
|
||||
foreground: '#eee',
|
||||
cursor: '#a29bfe',
|
||||
selectionBackground: 'rgba(162, 155, 254, 0.3)'
|
||||
},
|
||||
allowProposedApi: true
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(webLinksAddon);
|
||||
|
||||
try {
|
||||
const webglAddon = new WebglAddon.WebglAddon();
|
||||
webglAddon.onContextLoss(() => { webglAddon.dispose(); });
|
||||
term.loadAddon(webglAddon);
|
||||
} catch (e) {
|
||||
console.warn('WebGL addon failed:', e);
|
||||
}
|
||||
|
||||
term.open(document.getElementById('terminal'));
|
||||
fitAddon.fit();
|
||||
|
||||
document.title = 'tmux: ' + validArg;
|
||||
|
||||
function sendInput(data) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const payload = textEncoder.encode(data);
|
||||
const buf = new Uint8Array(payload.length + 1);
|
||||
buf[0] = MSG_INPUT.charCodeAt(0);
|
||||
buf.set(payload, 1);
|
||||
ws.send(buf.buffer);
|
||||
}
|
||||
|
||||
function sendResize() {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const json = JSON.stringify({ columns: term.cols, rows: term.rows });
|
||||
const payload = textEncoder.encode(json);
|
||||
const buf = new Uint8Array(payload.length + 1);
|
||||
buf[0] = MSG_RESIZE.charCodeAt(0);
|
||||
buf.set(payload, 1);
|
||||
ws.send(buf.buffer);
|
||||
}
|
||||
|
||||
term.onData(sendInput);
|
||||
term.onBinary((data) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const bytes = new Uint8Array(data.length + 1);
|
||||
bytes[0] = MSG_INPUT.charCodeAt(0);
|
||||
for (let i = 0; i < data.length; i++) bytes[i + 1] = data.charCodeAt(i);
|
||||
ws.send(bytes.buffer);
|
||||
});
|
||||
|
||||
term.onResize(() => sendResize());
|
||||
window.addEventListener('resize', () => fitAddon.fit());
|
||||
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && term.hasSelection()) {
|
||||
navigator.clipboard.writeText(term.getSelection());
|
||||
return false;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
|
||||
return false; // let browser paste event fire
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
document.addEventListener('paste', async (e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const blob = item.getAsFile();
|
||||
if (!blob) { showToast('Failed to read image', 'error'); return; }
|
||||
|
||||
showToast('Uploading image...', '');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', blob);
|
||||
const resp = await fetch('/clipboard/upload', { method: 'POST', body: formData });
|
||||
if (!resp.ok) { showToast('Upload failed: ' + await resp.text(), 'error', 5000); return; }
|
||||
const { path } = await resp.json();
|
||||
sendInput(path);
|
||||
showToast('Pasted: ' + path, 'success', 4000);
|
||||
} catch (err) {
|
||||
showToast('Upload error: ' + err.message, 'error', 5000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const text = e.clipboardData.getData('text');
|
||||
if (text) {
|
||||
e.preventDefault();
|
||||
sendInput(text);
|
||||
}
|
||||
}, true);
|
||||
|
||||
document.getElementById('paste-btn').addEventListener('click', async () => {
|
||||
try {
|
||||
if (navigator.clipboard.read) {
|
||||
const items = await navigator.clipboard.read();
|
||||
for (const item of items) {
|
||||
const imageType = item.types.find(t => t.startsWith('image/'));
|
||||
if (imageType) {
|
||||
const blob = await item.getType(imageType);
|
||||
showToast('Uploading image...', '');
|
||||
const formData = new FormData();
|
||||
formData.append('image', blob);
|
||||
const resp = await fetch('/clipboard/upload', { method: 'POST', body: formData });
|
||||
if (!resp.ok) { showToast('Upload failed: ' + await resp.text(), 'error', 5000); return; }
|
||||
const { path } = await resp.json();
|
||||
sendInput(path);
|
||||
showToast('Pasted: ' + path, 'success', 4000);
|
||||
return;
|
||||
}
|
||||
if (item.types.includes('text/plain')) {
|
||||
const blob = await item.getType('text/plain');
|
||||
const text = await blob.text();
|
||||
if (text) { sendInput(text); return; }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) sendInput(text);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Clipboard access denied', 'error', 3000);
|
||||
console.error('Clipboard read failed:', err);
|
||||
}
|
||||
term.focus();
|
||||
});
|
||||
|
||||
document.getElementById('img-btn').addEventListener('click', () => {
|
||||
document.getElementById('img-input').click();
|
||||
});
|
||||
document.getElementById('img-input').addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
e.target.value = ''; // reset so same file can be re-selected
|
||||
|
||||
showToast('Uploading image...', '');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
const resp = await fetch('/clipboard/upload', { method: 'POST', body: formData });
|
||||
if (!resp.ok) { showToast('Upload failed: ' + await resp.text(), 'error', 5000); return; }
|
||||
const { path } = await resp.json();
|
||||
sendInput(path);
|
||||
showToast('Pasted: ' + path, 'success', 4000);
|
||||
} catch (err) {
|
||||
showToast('Upload error: ' + err.message, 'error', 5000);
|
||||
}
|
||||
term.focus();
|
||||
});
|
||||
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const argSuffix = '?arg=' + encodeURIComponent(validArg);
|
||||
const base = location.pathname.replace(/\/+$/, '');
|
||||
const wsUrl = proto + '//' + location.host + base + '/ws' + argSuffix;
|
||||
|
||||
fetch(base + '/token' + argSuffix, { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(tokenData => {
|
||||
const token = tokenData.token || '';
|
||||
ws = new WebSocket(wsUrl, ['tty']);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('Connected to ttyd (session: ' + validArg + ')');
|
||||
const initMsg = JSON.stringify({
|
||||
AuthToken: token,
|
||||
columns: term.cols,
|
||||
rows: term.rows
|
||||
});
|
||||
ws.send(initMsg);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = event.data;
|
||||
if (data instanceof ArrayBuffer) {
|
||||
const view = new Uint8Array(data);
|
||||
if (view.length < 1) return;
|
||||
const msgType = view[0];
|
||||
const payload = view.slice(1);
|
||||
|
||||
switch (msgType) {
|
||||
case MSG_OUTPUT:
|
||||
term.write(payload);
|
||||
break;
|
||||
case MSG_SET_PREFS:
|
||||
try {
|
||||
const prefs = JSON.parse(textDecoder.decode(payload));
|
||||
console.log('ttyd prefs:', prefs);
|
||||
} catch (e) {}
|
||||
break;
|
||||
case MSG_SET_TITLE:
|
||||
document.title = textDecoder.decode(payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
term.write('\r\n\x1b[31mDisconnected. Reconnecting...\x1b[0m\r\n');
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = (e) => console.error('WebSocket error:', e);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Token fetch failed:', err);
|
||||
term.write('\r\n\x1b[31mFailed to connect. Retrying...\x1b[0m\r\n');
|
||||
setTimeout(connect, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
connect();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #000; }
|
||||
#terminal { height: 100%; width: 100%; }
|
||||
.hidden { display: none !important; }
|
||||
#toast {
|
||||
position: fixed; top: 16px; right: 16px; z-index: 9999;
|
||||
background: #1a1a2e; color: #a29bfe; border: 1px solid #333;
|
||||
|
|
@ -43,10 +44,61 @@
|
|||
#img-btn:hover { background: rgba(108, 92, 231, 0.85); }
|
||||
#img-btn:active { transform: scale(0.92); }
|
||||
#img-input { display: none; }
|
||||
|
||||
/* Lobby */
|
||||
#lobby {
|
||||
display: none; padding: 32px; height: 100vh; box-sizing: border-box;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace;
|
||||
color: #eee; background: #1a1a2e; overflow-y: auto;
|
||||
}
|
||||
#lobby.visible { display: block; }
|
||||
.lobby-header { font-size: 22px; color: #a29bfe; margin: 0 0 4px 0; }
|
||||
.lobby-sub { color: #888; font-size: 13px; margin: 0 0 24px 0; }
|
||||
.new-row { display: flex; gap: 8px; margin-bottom: 24px; max-width: 640px; }
|
||||
.new-row input {
|
||||
flex: 1; padding: 10px 14px; border-radius: 8px; border: 1px solid #333;
|
||||
background: #0f0f1f; color: #eee; font-family: inherit; font-size: 14px;
|
||||
}
|
||||
.new-row input:focus { outline: none; border-color: #a29bfe; }
|
||||
.new-row button {
|
||||
padding: 10px 18px; border-radius: 8px; border: 1px solid rgba(162, 155, 254, 0.4);
|
||||
background: rgba(108, 92, 231, 0.6); color: #eee; font-family: inherit;
|
||||
font-size: 14px; cursor: pointer;
|
||||
}
|
||||
.new-row button:hover { background: rgba(108, 92, 231, 0.85); }
|
||||
.session-list { display: grid; gap: 12px; max-width: 640px; }
|
||||
.session-card {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
background: #0f0f1f; border: 1px solid #2a2a3f; border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
.session-meta { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.session-name { font-size: 16px; font-weight: 600; color: #eee; }
|
||||
.session-detail { font-size: 12px; color: #888; }
|
||||
.session-detail.attached { color: #2ecc71; }
|
||||
.session-actions { display: flex; gap: 8px; }
|
||||
.session-actions button {
|
||||
padding: 8px 14px; border-radius: 6px; border: 1px solid #333;
|
||||
background: #1f1f33; color: #eee; font-family: inherit;
|
||||
font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.session-actions button.open { color: #a29bfe; border-color: rgba(162, 155, 254, 0.4); }
|
||||
.session-actions button.kill { color: #e74c3c; border-color: rgba(231, 76, 60, 0.4); }
|
||||
.session-actions button:hover { filter: brightness(1.3); }
|
||||
.empty { color: #888; font-style: italic; padding: 16px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="terminal"></div>
|
||||
<div id="lobby">
|
||||
<h1 class="lobby-header">tmux sessions</h1>
|
||||
<p class="lobby-sub">Pick an existing session or create a new one. Sessions persist after you close the tab.</p>
|
||||
<div class="new-row">
|
||||
<input id="new-name" type="text" placeholder="new session name (a-z, 0-9, _, -)" maxlength="32" autocomplete="off">
|
||||
<button id="new-btn">Create & Open</button>
|
||||
</div>
|
||||
<div id="session-list" class="session-list"></div>
|
||||
</div>
|
||||
<div id="toast"></div>
|
||||
<button id="img-btn" title="Upload image">📷</button>
|
||||
<button id="paste-btn" title="Paste from clipboard">📋</button>
|
||||
|
|
@ -58,17 +110,11 @@
|
|||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
// ttyd binary protocol: first byte is message type
|
||||
const MSG_OUTPUT = 0x30; // '0' - terminal output
|
||||
const MSG_SET_PREFS = 0x31; // '1' - JSON preferences
|
||||
const MSG_SET_TITLE = 0x32; // '2' - window title
|
||||
|
||||
const MSG_INPUT = '0'; // client sends: '0' + data
|
||||
const MSG_RESIZE = '1'; // client sends: '1' + JSON {columns, rows}
|
||||
|
||||
let ws = null;
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
const NAME_RE = /^[a-zA-Z0-9_-]{1,32}$/;
|
||||
const SESSIONS_API = '/api/sessions/sessions';
|
||||
const params = new URLSearchParams(location.search);
|
||||
const rawArg = params.get('arg');
|
||||
const validArg = rawArg && NAME_RE.test(rawArg) ? rawArg : null;
|
||||
|
||||
function showToast(msg, type, duration) {
|
||||
const el = document.getElementById('toast');
|
||||
|
|
@ -78,6 +124,153 @@
|
|||
el._timer = setTimeout(() => { el.className = ''; }, duration || 3000);
|
||||
}
|
||||
|
||||
function clearChildren(el) {
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
}
|
||||
|
||||
function emptyState(text) {
|
||||
const e = document.createElement('div');
|
||||
e.className = 'empty';
|
||||
e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
if (!validArg) {
|
||||
// ============================================================
|
||||
// LOBBY MODE — no valid ?arg=, show session picker
|
||||
// ============================================================
|
||||
document.getElementById('terminal').classList.add('hidden');
|
||||
document.getElementById('paste-btn').classList.add('hidden');
|
||||
document.getElementById('img-btn').classList.add('hidden');
|
||||
document.getElementById('lobby').classList.add('visible');
|
||||
document.title = 'tmux sessions';
|
||||
|
||||
const listEl = document.getElementById('session-list');
|
||||
const newNameEl = document.getElementById('new-name');
|
||||
const newBtnEl = document.getElementById('new-btn');
|
||||
|
||||
function relativeTime(epochSec) {
|
||||
if (!epochSec) return '';
|
||||
const diff = Math.floor(Date.now() / 1000) - epochSec;
|
||||
if (diff < 60) return diff + 's ago';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
function openSession(name) {
|
||||
if (!NAME_RE.test(name)) { showToast('Invalid name', 'error'); return; }
|
||||
location.search = '?arg=' + encodeURIComponent(name);
|
||||
}
|
||||
|
||||
async function killSession(name) {
|
||||
if (!NAME_RE.test(name)) { showToast('Invalid name', 'error'); return; }
|
||||
if (!confirm('Kill tmux session "' + name + '"? Any running processes inside it will be terminated.')) return;
|
||||
try {
|
||||
const resp = await fetch(SESSIONS_API + '/' + encodeURIComponent(name), {
|
||||
method: 'DELETE', credentials: 'same-origin'
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast('Killed ' + name, 'success');
|
||||
} else if (resp.status === 404) {
|
||||
showToast('Session not found', 'error');
|
||||
} else {
|
||||
showToast('Kill failed: HTTP ' + resp.status, 'error');
|
||||
}
|
||||
renderLobby();
|
||||
} catch (err) {
|
||||
showToast('Kill error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderCard(s) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'session-card';
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'session-meta';
|
||||
const name = document.createElement('div');
|
||||
name.className = 'session-name';
|
||||
name.textContent = s.name;
|
||||
meta.appendChild(name);
|
||||
const detail = document.createElement('div');
|
||||
detail.className = 'session-detail' + (s.attached > 0 ? ' attached' : '');
|
||||
const attachedText = s.attached > 0 ? (s.attached + ' attached') : 'idle';
|
||||
detail.textContent = attachedText + ' · last activity ' + relativeTime(s.lastActivity);
|
||||
meta.appendChild(detail);
|
||||
card.appendChild(meta);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'session-actions';
|
||||
const openBtn = document.createElement('button');
|
||||
openBtn.className = 'open';
|
||||
openBtn.textContent = 'Open';
|
||||
openBtn.onclick = () => openSession(s.name);
|
||||
actions.appendChild(openBtn);
|
||||
const killBtn = document.createElement('button');
|
||||
killBtn.className = 'kill';
|
||||
killBtn.textContent = 'Kill';
|
||||
killBtn.onclick = () => killSession(s.name);
|
||||
actions.appendChild(killBtn);
|
||||
card.appendChild(actions);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
async function renderLobby() {
|
||||
try {
|
||||
const resp = await fetch(SESSIONS_API, { credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
clearChildren(listEl);
|
||||
listEl.appendChild(emptyState('Failed to load sessions: HTTP ' + resp.status));
|
||||
return;
|
||||
}
|
||||
const sessions = await resp.json();
|
||||
clearChildren(listEl);
|
||||
if (sessions.length === 0) {
|
||||
listEl.appendChild(emptyState('No tmux sessions. Create one above.'));
|
||||
return;
|
||||
}
|
||||
sessions.sort((a, b) => b.lastActivity - a.lastActivity);
|
||||
for (const s of sessions) listEl.appendChild(renderCard(s));
|
||||
} catch (err) {
|
||||
clearChildren(listEl);
|
||||
listEl.appendChild(emptyState('Error: ' + err.message));
|
||||
}
|
||||
}
|
||||
|
||||
newBtnEl.addEventListener('click', () => {
|
||||
const name = newNameEl.value.trim();
|
||||
if (!NAME_RE.test(name)) {
|
||||
showToast('Name must match ^[a-zA-Z0-9_-]{1,32}$', 'error', 4000);
|
||||
return;
|
||||
}
|
||||
openSession(name);
|
||||
});
|
||||
newNameEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') newBtnEl.click();
|
||||
});
|
||||
|
||||
renderLobby();
|
||||
setInterval(renderLobby, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// TERMINAL MODE — valid ?arg=<session>, attach via ttyd
|
||||
// ============================================================
|
||||
|
||||
// ttyd binary protocol
|
||||
const MSG_OUTPUT = 0x30; // '0' - terminal output
|
||||
const MSG_SET_PREFS = 0x31; // '1' - JSON preferences
|
||||
const MSG_SET_TITLE = 0x32; // '2' - window title
|
||||
const MSG_INPUT = '0'; // client → server: input data
|
||||
const MSG_RESIZE = '1'; // client → server: {columns, rows}
|
||||
|
||||
let ws = null;
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace",
|
||||
|
|
@ -107,7 +300,8 @@
|
|||
term.open(document.getElementById('terminal'));
|
||||
fitAddon.fit();
|
||||
|
||||
// Send binary input to ttyd
|
||||
document.title = 'tmux: ' + validArg;
|
||||
|
||||
function sendInput(data) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const payload = textEncoder.encode(data);
|
||||
|
|
@ -117,7 +311,6 @@
|
|||
ws.send(buf.buffer);
|
||||
}
|
||||
|
||||
// Send resize
|
||||
function sendResize() {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const json = JSON.stringify({ columns: term.cols, rows: term.rows });
|
||||
|
|
@ -128,7 +321,6 @@
|
|||
ws.send(buf.buffer);
|
||||
}
|
||||
|
||||
// Terminal input
|
||||
term.onData(sendInput);
|
||||
term.onBinary((data) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
|
@ -138,11 +330,9 @@
|
|||
ws.send(bytes.buffer);
|
||||
});
|
||||
|
||||
// Resize handling
|
||||
term.onResize(() => sendResize());
|
||||
window.addEventListener('resize', () => fitAddon.fit());
|
||||
|
||||
// Clipboard: Ctrl+V / Cmd+V
|
||||
term.attachCustomKeyEventHandler((e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && term.hasSelection()) {
|
||||
navigator.clipboard.writeText(term.getSelection());
|
||||
|
|
@ -154,7 +344,6 @@
|
|||
return true;
|
||||
});
|
||||
|
||||
// Image + text paste
|
||||
document.addEventListener('paste', async (e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
|
@ -190,13 +379,11 @@
|
|||
}
|
||||
}, true);
|
||||
|
||||
// Paste button (iOS + universal)
|
||||
document.getElementById('paste-btn').addEventListener('click', async () => {
|
||||
try {
|
||||
if (navigator.clipboard.read) {
|
||||
const items = await navigator.clipboard.read();
|
||||
for (const item of items) {
|
||||
// Check for image types
|
||||
const imageType = item.types.find(t => t.startsWith('image/'));
|
||||
if (imageType) {
|
||||
const blob = await item.getType(imageType);
|
||||
|
|
@ -210,7 +397,6 @@
|
|||
showToast('Pasted: ' + path, 'success', 4000);
|
||||
return;
|
||||
}
|
||||
// Text
|
||||
if (item.types.includes('text/plain')) {
|
||||
const blob = await item.getType('text/plain');
|
||||
const text = await blob.text();
|
||||
|
|
@ -218,7 +404,6 @@
|
|||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) sendInput(text);
|
||||
}
|
||||
|
|
@ -229,7 +414,6 @@
|
|||
term.focus();
|
||||
});
|
||||
|
||||
// Image upload button (works on iOS + all browsers)
|
||||
document.getElementById('img-btn').addEventListener('click', () => {
|
||||
document.getElementById('img-input').click();
|
||||
});
|
||||
|
|
@ -253,12 +437,13 @@
|
|||
term.focus();
|
||||
});
|
||||
|
||||
// Connect
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = proto + '//' + location.host + location.pathname.replace(/\/+$/, '') + '/ws';
|
||||
const argSuffix = '?arg=' + encodeURIComponent(validArg);
|
||||
const base = location.pathname.replace(/\/+$/, '');
|
||||
const wsUrl = proto + '//' + location.host + base + '/ws' + argSuffix;
|
||||
|
||||
fetch(location.pathname.replace(/\/+$/, '') + '/token', { credentials: 'same-origin' })
|
||||
fetch(base + '/token' + argSuffix, { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(tokenData => {
|
||||
const token = tokenData.token || '';
|
||||
|
|
@ -266,8 +451,7 @@
|
|||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('Connected to ttyd');
|
||||
// Send auth + initial size as JSON
|
||||
console.log('Connected to ttyd (session: ' + validArg + ')');
|
||||
const initMsg = JSON.stringify({
|
||||
AuthToken: token,
|
||||
columns: term.cols,
|
||||
|
|
|
|||
|
|
@ -217,49 +217,15 @@ module "ingress_ro" {
|
|||
}
|
||||
}
|
||||
|
||||
# === Multi-session terminal: term.viktorbarzin.me ===
|
||||
# === Multi-session lobby cutover on terminal.viktorbarzin.me ===
|
||||
#
|
||||
# Additive lobby UX on a fresh hostname + ports — does not touch the existing
|
||||
# terminal.viktorbarzin.me (7681), terminal-ro.viktorbarzin.me (7682) or
|
||||
# /clipboard/* (7683) wiring above. DevVM-side units (ttyd-multi.service on
|
||||
# port 7685, tmux-api.service on port 7684) ship from
|
||||
# files/devvm/ — see files/devvm/README.md.
|
||||
|
||||
# Service+Endpoints → ttyd-multi on the DevVM (port 7685).
|
||||
resource "kubernetes_service" "terminal_multi" {
|
||||
metadata {
|
||||
name = "terminal-multi"
|
||||
namespace = kubernetes_namespace.terminal.metadata[0].name
|
||||
labels = {
|
||||
app = "terminal-multi"
|
||||
}
|
||||
}
|
||||
|
||||
spec {
|
||||
port {
|
||||
name = "http"
|
||||
port = 80
|
||||
target_port = 7685
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_endpoints" "terminal_multi" {
|
||||
metadata {
|
||||
name = "terminal-multi"
|
||||
namespace = kubernetes_namespace.terminal.metadata[0].name
|
||||
}
|
||||
|
||||
subset {
|
||||
address {
|
||||
ip = "10.0.10.10"
|
||||
}
|
||||
port {
|
||||
name = "http"
|
||||
port = 7685
|
||||
}
|
||||
}
|
||||
}
|
||||
# The `terminal` Service+Endpoints (port 7681) above now backs the
|
||||
# multi-session lobby — ttyd.service on the DevVM runs tmux-attach.sh with
|
||||
# `-a`, serving index.html (the lobby HTML, ex-index-multi.html). DevVM-side
|
||||
# units ship from files/devvm/ — see files/devvm/README.md.
|
||||
#
|
||||
# The lobby's REST API (`/api/sessions/*`) is reverse-proxied to a small Go
|
||||
# binary on port 7684 via the IngressRoute below.
|
||||
|
||||
# Service+Endpoints → tmux-api on the DevVM (port 7684).
|
||||
resource "kubernetes_service" "tmux_api" {
|
||||
|
|
@ -297,30 +263,10 @@ resource "kubernetes_endpoints" "tmux_api" {
|
|||
}
|
||||
}
|
||||
|
||||
# Public ingress for the lobby + per-session attach.
|
||||
# Hostname: term.viktorbarzin.me (via `host = "term"` override).
|
||||
module "ingress_multi" {
|
||||
source = "../../modules/kubernetes/ingress_factory"
|
||||
dns_type = "proxied"
|
||||
namespace = kubernetes_namespace.terminal.metadata[0].name
|
||||
name = "terminal-multi"
|
||||
host = "term"
|
||||
tls_secret_name = var.tls_secret_name
|
||||
auth = "required"
|
||||
extra_annotations = {
|
||||
"gethomepage.dev/enabled" = "true"
|
||||
"gethomepage.dev/name" = "Terminal (Multi)"
|
||||
"gethomepage.dev/description" = "Multi-session tmux lobby (ttyd)"
|
||||
"gethomepage.dev/icon" = "mdi-console"
|
||||
"gethomepage.dev/group" = "Infrastructure"
|
||||
"gethomepage.dev/pod-selector" = ""
|
||||
}
|
||||
}
|
||||
|
||||
# IngressRoute: /api/sessions/* on term.viktorbarzin.me → tmux-api service.
|
||||
# Path-prefixed routes beat the catch-all module ingress above by
|
||||
# specificity, so the lobby HTML reaches tmux-api directly while everything
|
||||
# else flows to ttyd-multi.
|
||||
# IngressRoute: /api/sessions/* on terminal.viktorbarzin.me → tmux-api
|
||||
# service. Path-prefix specificity beats the catch-all `module.ingress`
|
||||
# (terminal.viktorbarzin.me → ttyd) above, so the lobby HTML reaches
|
||||
# tmux-api directly while everything else flows to ttyd.
|
||||
resource "kubernetes_manifest" "tmux_api_ingressroute" {
|
||||
manifest = {
|
||||
apiVersion = "traefik.io/v1alpha1"
|
||||
|
|
@ -332,7 +278,7 @@ resource "kubernetes_manifest" "tmux_api_ingressroute" {
|
|||
spec = {
|
||||
entryPoints = ["websecure"]
|
||||
routes = [{
|
||||
match = "Host(`term.viktorbarzin.me`) && PathPrefix(`/api/sessions/`)"
|
||||
match = "Host(`terminal.viktorbarzin.me`) && PathPrefix(`/api/sessions/`)"
|
||||
kind = "Rule"
|
||||
middlewares = [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue