From d456ffe75776267108c514b7f146664ba6764db1 Mon Sep 17 00:00:00 2001 From: iRedTea Date: Wed, 14 Oct 2026 14:43:06 +0300 Subject: [PATCH] feat: phone layout for the panel, and Termux-style extra keys in the terminal --- internal/server/web/static/app.css | 30 ++++- internal/server/web/templates/dashboard.html | 12 +- internal/server/web/templates/files.html | 22 +++- internal/server/web/templates/forwards.html | 23 +++- internal/server/web/templates/terminal.html | 129 ++++++++++++++++++- 5 files changed, 193 insertions(+), 23 deletions(-) diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 73ff463..9d4ebea 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -96,7 +96,31 @@ pre { padding: 12px; overflow-x: auto; font-size: 13px; white-space: pre-wrap; w .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } .spacer { flex: 1; } #terminal { width: 100%; height: calc(100vh - 150px); } -@media (max-width: 600px) { - header.topbar { padding: 12px 16px; } - header.topbar nav a { margin-left: 12px; } +/* Phones. A table marked .stack turns each row into a block of + "label: value" lines, the labels coming from data-l on the cells; a cell + marked .lead goes first, as the row's title. */ +@media (max-width: 640px) { + header.topbar { flex-wrap: wrap; gap: 6px 12px; padding: 10px 12px; } + header.topbar .brand { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; } + header.topbar nav { display: flex; align-items: center; gap: 16px; max-width: 100%; + overflow-x: auto; white-space: nowrap; scrollbar-width: none; } + header.topbar nav::-webkit-scrollbar { display: none; } + header.topbar nav > * { margin-left: 0 !important; flex: none; } + .wrap { padding: 16px 12px; } + .card { padding: 16px; border-radius: 8px; } + h1 { font-size: 20px; } + code { word-break: break-all; } + /* 16px and up, or iOS zooms the page into a field on focus. */ + input[type=text], input[type=password], input[type=number], select, textarea { font-size: 16px; } + table.stack thead { display: none; } + table.stack, table.stack tbody { display: block; } + table.stack tr { display: flex; flex-direction: column; gap: 2px; padding: 12px 0; border-bottom: 1px solid var(--border); } + table.stack tr:first-child { padding-top: 0; } + table.stack tr:last-child { border-bottom: 0; padding-bottom: 0; } + table.stack td { display: flex; justify-content: flex-start; align-items: center; gap: 0; + width: auto; padding: 2px 0; border: 0; text-align: right !important; overflow-wrap: anywhere; } + table.stack td[data-l]::before { content: attr(data-l); flex: none; margin-right: auto; padding-right: 12px; color: var(--muted); font-size: 13px; text-align: left; } + table.stack td.lead { order: -1; justify-content: flex-start; text-align: left !important; font-size: 16px; font-weight: 600; } + table.stack td.actions { width: auto; padding-top: 8px; justify-content: flex-start; } + table.stack td:empty { display: none; } } diff --git a/internal/server/web/templates/dashboard.html b/internal/server/web/templates/dashboard.html index bbdf1d9..3bd9b9c 100644 --- a/internal/server/web/templates/dashboard.html +++ b/internal/server/web/templates/dashboard.html @@ -21,16 +21,16 @@

Машины-клиенты

{{if .Agents}} - +
{{range .Agents}} - - - - - + + + + + ' + '' + - '' + - '' + + '' + + '' + '
СтатусИмяHostnameОС / АрхАктивностьДействия
{{if .Online}}online{{else}}offline{{end}}{{.Name}}{{.Hostname}}{{.OS}}/{{.Arch}}{{.Seen}}{{if .Online}}online{{else}}offline{{end}}{{.Name}}{{.Hostname}}{{.OS}}/{{.Arch}}{{.Seen}} '+(e.is_dir?'📁':'📄')+''+esc(e.name)+''+(e.is_dir?'':fmtSize(e.size))+''+fmtTime(e.mtime)+''+(e.is_dir?'':fmtSize(e.size))+''+fmtTime(e.mtime)+'
' + (isZip?'':'') + (e.is_dir?'':''+ICON_DL+'') + diff --git a/internal/server/web/templates/forwards.html b/internal/server/web/templates/forwards.html index 358cbc9..4bafcb2 100644 --- a/internal/server/web/templates/forwards.html +++ b/internal/server/web/templates/forwards.html @@ -24,6 +24,15 @@ color:var(--text); font-size:14px; cursor:pointer; } .under { min-height:20px; margin:12px 110px 0 0; font-size:13px; line-height:20px; } + /* Phones: two fields a line, the label and the button each a line of their own. */ + @media (max-width: 640px) { + .frow .fld, .frow .fld.addr { width:calc(50% - 6px); } + .frow .fld.grow { flex:1 1 100%; } + .frow .act { display:flex; align-items:center; gap:16px; width:100%; } + .frow .act button { flex:1; } + .frow .act label.check { position:static; transform:none; margin:0; } + .under { margin-right:0; } + } @@ -46,17 +55,17 @@

Правила

{{if .Rules}} - +
{{range .Rules}} - - - - - - + + + + +
ТипХост (порт)→ Клиент (порт)Слушать наМетка
{{.Proto}}:{{.HostPort}}127.0.0.1:{{.ClientPort}}{{.ListenAddr}}{{if and .NoUfw $.UfwEnabled}} · без ufw{{end}}{{.Label}} + {{.Proto}}:{{.HostPort}}127.0.0.1:{{.ClientPort}}{{.ListenAddr}}{{if and .NoUfw $.UfwEnabled}} · без ufw{{end}}{{.Label}}
diff --git a/internal/server/web/templates/terminal.html b/internal/server/web/templates/terminal.html index 02c7b67..89d3197 100644 --- a/internal/server/web/templates/terminal.html +++ b/internal/server/web/templates/terminal.html @@ -9,6 +9,24 @@ @@ -26,6 +44,22 @@
+
+ + + + + + + + + + + + + + +
@@ -33,11 +67,13 @@ (function () { const agentID = {{.Agent.ID}}; const statusEl = document.getElementById('status'); + const touch = window.matchMedia('(pointer: coarse)').matches; + if (touch) document.body.classList.add('touch'); function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = cls || ''; } const term = new Terminal({ cursorBlink: true, - fontSize: 14, + fontSize: touch ? 13 : 14, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', theme: { background: '#0c0f13' } }); @@ -126,17 +162,104 @@ term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n'); } - term.onData(function (data) { + function send(data) { if (closed || !sid) return; const bytes = new TextEncoder().encode(data); post(base + sid + '/in', { t: 'i', d: bytesToB64(bytes) }); + } + // What the keyboard types gets the CTRL / ALT of the extra keys, if armed. + term.onData(function (data) { send(withMods(data)); }); + + // --- Extra keys (touch screens) --- + // CTRL and ALT: a tap arms the key for the next key press, a long press + // keeps it on until tapped again. + const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked + const modBtn = {}; + document.querySelectorAll('#keys [data-mod]').forEach(function (b) { modBtn[b.dataset.mod] = b; }); + function showMods() { + for (const m in mods) { + modBtn[m].classList.toggle('on', mods[m] === 1); + modBtn[m].classList.toggle('lock', mods[m] === 2); + } + } + function useMods() { + const used = { ctrl: !!mods.ctrl, alt: !!mods.alt }; + for (const m in mods) if (mods[m] === 1) mods[m] = 0; + showMods(); + return used; + } + function ctrlChar(c) { + if (c === ' ' || c === '@') return '\x00'; + if (c === '?') return '\x7f'; + const code = c.toUpperCase().charCodeAt(0); + return code >= 64 && code <= 95 ? String.fromCharCode(code & 31) : c; + } + function withMods(data) { + if (!mods.ctrl && !mods.alt) return data; + const m = useMods(); + if (m.ctrl && data.length === 1) data = ctrlChar(data); + return m.alt ? '\x1b' + data : data; + } + // xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4). + const CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' }; + const TILDE = { pgup: 5, pgdn: 6 }; + function keySeq(k) { + const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0); + if (CSI[k]) { + if (p > 1) return '\x1b[1;' + p + CSI[k]; + return (term.modes.applicationCursorKeysMode ? '\x1bO' : '\x1b[') + CSI[k]; + } + if (TILDE[k]) return '\x1b[' + TILDE[k] + (p > 1 ? ';' + p : '') + '~'; + let s = k === 'esc' ? '\x1b' : k === 'tab' ? '\t' : k; + if (m.ctrl && s.length === 1) s = ctrlChar(s); + return m.alt ? '\x1b' + s : s; + } + let press = null; + document.querySelectorAll('#keys button').forEach(function (b) { + // Keep the focus (and the on-screen keyboard) on the terminal. + b.addEventListener('pointerdown', function (e) { + e.preventDefault(); + const mod = b.dataset.mod; + if (mod) press = { mod: mod, timer: setTimeout(function () { mods[mod] = 2; press = null; showMods(); }, 450) }; + }); + b.addEventListener('pointerup', function (e) { + e.preventDefault(); + const mod = b.dataset.mod; + if (mod) { + if (press && press.mod === mod) { + clearTimeout(press.timer); + press = null; + mods[mod] = mods[mod] ? 0 : 1; + showMods(); + } + return; + } + send(keySeq(b.dataset.k)); + }); + b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } }); + b.addEventListener('contextmenu', function (e) { e.preventDefault(); }); }); function sendResize() { if (closed || !sid) return; post(base + sid + '/in', { t: 'r', cols: term.cols, rows: term.rows }); } - window.addEventListener('resize', function () { fit.fit(); sendResize(); }); + function refit() { + if (touch && window.visualViewport) { + // Follow the visible area, so the extra keys sit right above the + // on-screen keyboard. + const vv = window.visualViewport; + document.body.style.top = vv.offsetTop + 'px'; + document.body.style.height = vv.height + 'px'; + } + fit.fit(); + } + window.addEventListener('resize', refit); + if (touch && window.visualViewport) { + window.visualViewport.addEventListener('resize', refit); + window.visualViewport.addEventListener('scroll', refit); + } + refit(); term.onResize(sendResize); window.addEventListener('pagehide', function () {