feat: phone layout for the panel, and Termux-style extra keys in the terminal
This commit is contained in:
1 parent
81a2c96e0b
commit
d456ffe757
5 files changed
+193
-23
No files matched your search
@@ -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; }
|
||||
}
|
||||
@@ -21,16 +21,16 @@
|
||||
<h1>Машины-клиенты</h1>
|
||||
<div class="card">
|
||||
{{if .Agents}}
|
||||
<table>
|
||||
<table class="stack">
|
||||
<thead><tr><th>Статус</th><th>Имя</th><th>Hostname</th><th>ОС / Арх</th><th>Активность</th><th>Действия</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Agents}}
|
||||
<tr data-agent="{{.ID}}">
|
||||
<td class="nowrap"><span class="dot {{if .Online}}on{{else}}off{{end}}"></span><span class="state">{{if .Online}}online{{else}}offline{{end}}</span></td>
|
||||
<td>{{.Name}}</td>
|
||||
<td class="muted">{{.Hostname}}</td>
|
||||
<td class="muted nowrap">{{.OS}}/{{.Arch}}</td>
|
||||
<td class="muted nowrap seen">{{.Seen}}</td>
|
||||
<td class="nowrap" data-l="Статус"><span class="dot {{if .Online}}on{{else}}off{{end}}"></span><span class="state">{{if .Online}}online{{else}}offline{{end}}</span></td>
|
||||
<td class="lead">{{.Name}}</td>
|
||||
<td class="muted" data-l="Hostname">{{.Hostname}}</td>
|
||||
<td class="muted nowrap" data-l="ОС / Арх">{{.OS}}/{{.Arch}}</td>
|
||||
<td class="muted nowrap seen" data-l="Активность">{{.Seen}}</td>
|
||||
<td class="actions">
|
||||
<div class="row" style="gap:6px">
|
||||
<a class="btn small icon" href="/agents/{{.ID}}/terminal" data-tip="Терминал" aria-label="Терминал">
|
||||
|
||||
@@ -27,6 +27,20 @@
|
||||
.ic { display:inline-block; width:18px; text-align:center; margin-right:6px; }
|
||||
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
|
||||
.act { display:flex; gap:6px; justify-content:flex-end; }
|
||||
table.fm .c-sz { width:120px; }
|
||||
table.fm .c-mt { width:160px; }
|
||||
table.fm .c-act { width:160px; }
|
||||
.fm .nm { overflow-wrap:anywhere; }
|
||||
/* Phones: no date column, and the row buttons only as wide as they are. */
|
||||
@media (max-width: 640px) {
|
||||
table.fm .c-mt, table.fm td.mt { display:none; }
|
||||
table.fm .c-sz { width:auto; white-space:nowrap; }
|
||||
table.fm .c-act { width:1%; }
|
||||
table.fm td, table.fm th { padding:7px 4px; }
|
||||
.ic { margin-right:4px; }
|
||||
.fm-bar input.fm-path { flex:1 1 100%; order:-1; }
|
||||
textarea#edit { font-size:13px; }
|
||||
}
|
||||
dialog { background:var(--panel); color:var(--text); border:1px solid var(--border);
|
||||
border-radius:12px; padding:0; max-width:900px; width:92vw; }
|
||||
dialog::backdrop { background:rgba(0,0,0,.6); }
|
||||
@@ -74,8 +88,8 @@
|
||||
<table class="fm">
|
||||
<thead><tr>
|
||||
<th style="width:28px"><input type="checkbox" id="all"></th>
|
||||
<th>Имя</th><th style="width:120px" class="sz">Размер</th>
|
||||
<th style="width:160px" class="sz">Изменён</th><th style="width:160px"></th>
|
||||
<th>Имя</th><th class="sz c-sz">Размер</th>
|
||||
<th class="sz c-mt">Изменён</th><th class="c-act"></th>
|
||||
</tr></thead>
|
||||
<tbody id="list"></tbody>
|
||||
</table>
|
||||
@@ -156,8 +170,8 @@
|
||||
tr.innerHTML =
|
||||
'<td><input type="checkbox" class="ck" data-n="'+encodeURIComponent(e.name)+'"></td>' +
|
||||
'<td class="nm"><span class="ic">'+(e.is_dir?'📁':'📄')+'</span>'+esc(e.name)+'</td>' +
|
||||
'<td class="sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' +
|
||||
'<td class="sz">'+fmtTime(e.mtime)+'</td>' +
|
||||
'<td class="sz c-sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' +
|
||||
'<td class="sz mt">'+fmtTime(e.mtime)+'</td>' +
|
||||
'<td><div class="act">' +
|
||||
(isZip?'<button class="btn ghost small" data-act="unzip">unzip</button>':'') +
|
||||
(e.is_dir?'':'<a class="btn ghost small icon" title="Скачать" href="'+dlHref+'" download>'+ICON_DL+'</a>') +
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -46,17 +55,17 @@
|
||||
<div class="card">
|
||||
<h2>Правила</h2>
|
||||
{{if .Rules}}
|
||||
<table>
|
||||
<table class="stack">
|
||||
<thead><tr><th>Тип</th><th>Хост (порт)</th><th>→ Клиент (порт)</th><th>Слушать на</th><th>Метка</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Rules}}
|
||||
<tr>
|
||||
<td><code>{{.Proto}}</code></td>
|
||||
<td class="muted">:{{.HostPort}}</td>
|
||||
<td class="muted">127.0.0.1:{{.ClientPort}}</td>
|
||||
<td class="muted">{{.ListenAddr}}{{if and .NoUfw $.UfwEnabled}} · без ufw{{end}}</td>
|
||||
<td>{{.Label}}</td>
|
||||
<td style="text-align:right">
|
||||
<td data-l="Тип"><code>{{.Proto}}</code></td>
|
||||
<td class="muted" data-l="Хост">:{{.HostPort}}</td>
|
||||
<td class="muted" data-l="→ Клиент">127.0.0.1:{{.ClientPort}}</td>
|
||||
<td class="muted" data-l="Слушать на">{{.ListenAddr}}{{if and .NoUfw $.UfwEnabled}} · без ufw{{end}}</td>
|
||||
<td class="lead">{{.Label}}</td>
|
||||
<td class="actions" style="text-align:right">
|
||||
<form method="post" action="/forwards/{{.ID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Удалить правило?')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
|
||||
@@ -9,6 +9,24 @@
|
||||
<style>
|
||||
#status.st-ok { color:#37c871; }
|
||||
#status.st-err { color:#ff5c5c; }
|
||||
/* Touch screens: the page is as tall as what the on-screen keyboard leaves,
|
||||
with a row of extra keys at the bottom, the way Termux does it. */
|
||||
.keys { display:none; }
|
||||
body.touch { position:fixed; left:0; right:0; top:0; height:100%; display:flex; flex-direction:column; overflow:hidden; }
|
||||
body.touch .wrap { flex:1; min-height:0; display:flex; width:100%; margin:0; padding:6px; }
|
||||
body.touch #terminal { flex:1; min-width:0; height:auto; }
|
||||
body.touch .keys {
|
||||
display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none;
|
||||
padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border);
|
||||
}
|
||||
.keys button {
|
||||
height:38px; padding:0; border:1px solid var(--border); border-radius:6px;
|
||||
background:#0c0f13; color:var(--text); font:500 13px ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
touch-action:manipulation; user-select:none; -webkit-user-select:none; -webkit-tap-highlight-color:transparent;
|
||||
}
|
||||
.keys button:active { background:var(--border); }
|
||||
.keys button.on { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||
.keys button.lock { background:var(--accent); border-color:#fff; color:#fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -26,6 +44,22 @@
|
||||
<div class="wrap" style="max-width:none">
|
||||
<div id="terminal"></div>
|
||||
</div>
|
||||
<div class="keys" id="keys">
|
||||
<button type="button" data-k="esc">ESC</button>
|
||||
<button type="button" data-k="/">/</button>
|
||||
<button type="button" data-k="-">-</button>
|
||||
<button type="button" data-k="home">HOME</button>
|
||||
<button type="button" data-k="up">↑</button>
|
||||
<button type="button" data-k="end">END</button>
|
||||
<button type="button" data-k="pgup">PGUP</button>
|
||||
<button type="button" data-k="tab">TAB</button>
|
||||
<button type="button" data-k="ctrl" data-mod="ctrl">CTRL</button>
|
||||
<button type="button" data-k="alt" data-mod="alt">ALT</button>
|
||||
<button type="button" data-k="left">←</button>
|
||||
<button type="button" data-k="down">↓</button>
|
||||
<button type="button" data-k="right">→</button>
|
||||
<button type="button" data-k="pgdn">PGDN</button>
|
||||
</div>
|
||||
|
||||
<script src="/static/xterm.js"></script>
|
||||
<script src="/static/addon-fit.js"></script>
|
||||
@@ -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 () {
|
||||
|
||||
Reference in new issue
Block a user