Merge remote-tracking branch 'origin/claude/condescending-ishizaka-3cff3a'
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# WebSSH 终端命令输入栏
|
||||
|
||||
**日期**: 2026-05-26
|
||||
**变更摘要**: 为 WebSSH 终端底部新增命令输入栏,避免直接在 xterm 中逐字符输入导致的显示错乱问题
|
||||
|
||||
## 动机
|
||||
|
||||
1. 用户直接在 xterm 终端中逐字符输入时,SSH echo 与 ANSI prompt 序列交错,导致显示错乱(如 "1111312312oot@mqtele:~#")
|
||||
2. 根因:逐字符发送 WebSocket 消息时,`read(4096)` 返回的数据可能混合 echo 和 prompt 的 ANSI 片段,时序依赖导致渲染错乱
|
||||
3. 命令输入栏一次性发送完整命令 + `\r`,完全避免逐字符 echo 交错问题
|
||||
4. 同时提供 Ctrl+C/D 和 Tab 快捷按钮,方便运维操作
|
||||
|
||||
## 变更内容
|
||||
|
||||
### web/app/terminal.html — 3处修改
|
||||
|
||||
**修改1: CSS 布局调整**
|
||||
- 旧:`#terminalWrap{height:calc(100vh - 48px)}` — 固定高度,终端占满
|
||||
- 新:`#terminalWrap{flex:1;min-height:0}` — flex 弹性布局,为底部命令栏留空间
|
||||
- 全屏样式简化:移除 `height:100vh`,flex:1 + fixed inset 自动撑满
|
||||
|
||||
**修改2: HTML 结构 — 新增命令输入栏**
|
||||
- terminalWrap 改为 `flex flex-col` 容器
|
||||
- `#terminal` 加 `flex-1 min-h-0` 占据剩余空间
|
||||
- 新增 `#cmdBar` 底部栏,包含:
|
||||
- 命令提示符 `❯`
|
||||
- `<input id="cmdInput">` — 命令输入框,monospace 字体
|
||||
- `发送` 按钮 — 调用 `sendCmd()`
|
||||
- `⌃C` / `⌃D` / `Tab` 快捷按钮 — 分别调用 `sendCtrl()` / `sendKey()`
|
||||
|
||||
**修改3: JavaScript — 命令输入逻辑**
|
||||
- `cmdHistory` / `cmdHistoryIdx` — 命令历史记录 + 上下箭头导航
|
||||
- `cmdInput keydown` — Enter 发送、ArrowUp/Down 浏览历史
|
||||
- `sendCmd()` — 发送完整命令 + `\r`,记入历史,清空输入框
|
||||
- `sendCtrl(key)` — 发送控制字符(charCode - 96)
|
||||
- `sendKey(key)` — 发送原始按键(如 Tab)
|
||||
|
||||
## 技术分析
|
||||
|
||||
逐字符输入问题:
|
||||
```
|
||||
用户输入 'w' → WS send {type:DATA, data:"w"}
|
||||
→ SSH echo "\033[...w" → read(4096) 可能返回 "w\033[...root@..."
|
||||
→ xterm 渲染时 ANSI 序列与 echo 字符交错 → 显示错乱
|
||||
```
|
||||
|
||||
命令输入栏解决方案:
|
||||
```
|
||||
用户输入 "whoami" → 点发送 → WS send {type:DATA, data:"whoami\r"}
|
||||
→ SSH 完整回显 "whoami\r\nroot\r\n" → 一次返回 → 正常渲染
|
||||
```
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `web/app/terminal.html` — 终端页面(CSS + HTML + JS 共3处修改)
|
||||
|
||||
## 是否需迁移/重启
|
||||
|
||||
- 不需重启后端(纯前端改动)
|
||||
- 不需 DB 迁移
|
||||
|
||||
## 验证方式
|
||||
|
||||
1. 打开 terminal.html → 底部显示命令输入栏(❯ 提示符 + 输入框 + 发送按钮)
|
||||
2. 输入 `whoami` → 点发送 → 终端显示 `root`(无显示错乱)
|
||||
3. 输入 `ls` → Enter 键发送 → 正常显示文件列表
|
||||
4. ArrowUp → 回显上一条命令 `ls`,ArrowDown → 清空
|
||||
5. 点击 `⌃C` 按钮 → 中断当前命令
|
||||
6. 点击 `Tab` 按钮 → 触发自动补全
|
||||
7. 终端 xterm 区域自动占满输入栏上方空间
|
||||
8. 全屏模式下输入栏仍然可见
|
||||
@@ -0,0 +1,59 @@
|
||||
# WebSSH 终端输入回显延迟修复
|
||||
|
||||
**日期**: 2026-05-26
|
||||
**变更摘要**: 修复 WebSSH 终端输入字符不即时显示的问题 — 将 async-for (readline) 改为 read() + 降低 bufsize
|
||||
|
||||
## 动机
|
||||
|
||||
1. 用户在终端输入字符后,字符不会即时显示(echo 延迟),必须按 Enter 后才一次性出现
|
||||
2. 根因:`async for data in shell.stdout` 内部调用 `readline()` → `readuntil('\n')`,会缓冲所有数据直到遇到换行符
|
||||
3. 交互式终端中,单字符 echo 永远不包含换行符,导致所有输入字符被缓冲直到用户按 Enter
|
||||
|
||||
## 变更内容
|
||||
|
||||
### server/api/webssh.py — 2处修改
|
||||
|
||||
**修改1: `_read_shell_output()` 函数 — 消除 readline 缓冲**
|
||||
- 旧:`async for data in shell.stdout:` (内部 readline,等换行才 yield)
|
||||
- 新:`while not shell.stdout.at_eof(): data = await shell.stdout.read(4096)` (有数据即返回)
|
||||
- `read(n)` 使用 `exact=False`,只要缓冲区有数据就立即返回(最多 n 字节),不等换行
|
||||
|
||||
**修改2: `create_process()` 调用 — 降低 bufsize**
|
||||
- 旧:`conn.create_process(term_type="xterm-256color", term_size=(24, 80))` (默认 bufsize=128KB)
|
||||
- 新:`conn.create_process(term_type="xterm-256color", term_size=(24, 80), bufsize=4096)`
|
||||
- 两处 create_process(主连接 + 失效重试连接)均添加 `bufsize=4096`
|
||||
- 4KB 缓冲区足够单次交互 echo,同时大幅降低延迟
|
||||
|
||||
## 技术分析
|
||||
|
||||
asyncssh 数据流路径:
|
||||
```
|
||||
async for data in shell.stdout
|
||||
→ SSHStreamProcess.__aiter__()
|
||||
→ SSHStreamSession.aiter()
|
||||
→ readline()
|
||||
→ readuntil('\n') ← 阻塞直到换行符
|
||||
```
|
||||
|
||||
修复后路径:
|
||||
```
|
||||
while not shell.stdout.at_eof():
|
||||
data = await shell.stdout.read(4096)
|
||||
→ SSHStreamSession.read(4096, exact=False) ← 有数据即返回
|
||||
```
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/api/webssh.py` — WebSSH WebSocket 处理(2处修改)
|
||||
|
||||
## 是否需迁移/重启
|
||||
|
||||
- 需重启后端(webssh.py 修改)
|
||||
- 前端无改动
|
||||
|
||||
## 验证方式
|
||||
|
||||
1. 打开 terminal.html?server_id=8 → WebSocket 保持 OPEN,状态显示「已连接」
|
||||
2. 输入 `whoami` → 字符即时显示(不等 Enter),按 Enter 后立即显示 `root`
|
||||
3. 后端日志无 `shell creation failed` 错误
|
||||
4. 终端交互流畅,无 echo 延迟
|
||||
@@ -453,9 +453,10 @@ async def batch_install_agent(
|
||||
if not api_key:
|
||||
return BatchAgentResultItem(server_id=sid, server_name=server.name, success=False, error="请先生成 Agent API Key")
|
||||
install_cmd = (
|
||||
f"curl -fsSL {shlex.quote(base_url)}/agent/install.sh | bash -s -- "
|
||||
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/install.sh -o \"$TMP\" && bash \"$TMP\" -- "
|
||||
f"--url {shlex.quote(base_url)} --key {shlex.quote(api_key)} "
|
||||
f"--id {sid} --port {shlex.quote(str(server.agent_port or 8601))}"
|
||||
f"; RC=$?; rm -f \"$TMP\"; exit $RC"
|
||||
)
|
||||
r = await exec_ssh_command(server, install_cmd, timeout=120)
|
||||
ok = r["exit_code"] == 0
|
||||
@@ -868,11 +869,12 @@ async def install_agent_remote(
|
||||
|
||||
agent_port = server.agent_port or 8601
|
||||
|
||||
# Build install command (all variables shell-escaped to prevent injection)
|
||||
# Build install command — download first, then execute (avoids pipe hiding curl errors)
|
||||
install_cmd = (
|
||||
f"curl -fsSL {shlex.quote(base_url)}/agent/install.sh | bash -s -- "
|
||||
f"TMP=$(mktemp) && curl -fsSL {shlex.quote(base_url)}/agent/install.sh -o \"$TMP\" && bash \"$TMP\" -- "
|
||||
f"--url {shlex.quote(base_url)} --key {shlex.quote(api_key)} "
|
||||
f"--id {id} --port {shlex.quote(str(agent_port))}"
|
||||
f"; RC=$?; rm -f \"$TMP\"; exit $RC"
|
||||
)
|
||||
|
||||
# Execute via SSH
|
||||
|
||||
+7
-1
@@ -448,4 +448,10 @@ app.include_router(search_router)
|
||||
# In production, Nginx serves these directly; this is for dev/standalone mode.
|
||||
WEB_APP_DIR = ROOT_DIR / "web" / "app"
|
||||
if WEB_APP_DIR.is_dir():
|
||||
app.mount("/app", StaticFiles(directory=str(WEB_APP_DIR), html=True), name="static_app")
|
||||
app.mount("/app", StaticFiles(directory=str(WEB_APP_DIR), html=True), name="static_app")
|
||||
|
||||
# ── Agent static files: serve web/agent/ at /agent/ (install.sh, agent.py, etc.) ──
|
||||
# Required by remote install: curl -fsSL {base_url}/agent/install.sh
|
||||
WEB_AGENT_DIR = ROOT_DIR / "web" / "agent"
|
||||
if WEB_AGENT_DIR.is_dir():
|
||||
app.mount("/agent", StaticFiles(directory=str(WEB_AGENT_DIR)), name="static_agent")
|
||||
+49
-3
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html><html lang="zh-CN" x-data="{ darkMode: true, sidebarOpen: true, panelOpen: true, connected: false, serverName: '...', cols: 80, rows: 24, fullscreen: false }" x-bind:class="darkMode ? 'dark' : ''" x-init="$watch('darkMode', v => { localStorage.setItem('darkMode', v); document.documentElement.classList.toggle('dark', v); })"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Nexus — SSH终端</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><link rel="stylesheet" href="/app/vendor/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250);--color-surface:oklch(98.5% 0.002 250)}</style><style>.xterm{padding:4px}#terminalWrap{height:calc(100vh - 48px)}#terminalWrap.fs{height:100vh;position:fixed;inset:0;z-index:100}</style></head>
|
||||
<!DOCTYPE html><html lang="zh-CN" x-data="{ darkMode: true, sidebarOpen: true, panelOpen: true, connected: false, serverName: '...', cols: 80, rows: 24, fullscreen: false }" x-bind:class="darkMode ? 'dark' : ''" x-init="$watch('darkMode', v => { localStorage.setItem('darkMode', v); document.documentElement.classList.toggle('dark', v); })"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Nexus — SSH终端</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><link rel="stylesheet" href="/app/vendor/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250);--color-surface:oklch(98.5% 0.002 250)}</style><style>.xterm{padding:4px}#terminalWrap{flex:1;min-height:0}#terminalWrap.fs{position:fixed;inset:0;z-index:100}</style></head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex">
|
||||
|
||||
<!-- Left Sidebar -->
|
||||
@@ -26,7 +26,7 @@
|
||||
</header>
|
||||
|
||||
<!-- Terminal -->
|
||||
<div id="terminalWrap" class="bg-slate-950"><div id="terminal"></div></div>
|
||||
<div id="terminalWrap" class="bg-slate-950 flex flex-col"><div id="terminal" class="flex-1 min-h-0"></div><div id="cmdBar" class="bg-slate-900/95 border-t border-slate-800 px-3 py-2 flex items-center gap-2 shrink-0"><span class="text-brand-light text-sm font-mono select-none">❯</span><input id="cmdInput" type="text" placeholder="输入命令,Enter 发送…" class="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-white text-sm font-mono placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-brand/50" autocomplete="off" spellcheck="false"><button onclick="sendCmd()" class="px-3 py-1.5 bg-brand/80 hover:bg-brand text-white text-sm rounded transition">发送</button><div class="flex gap-1 border-l border-slate-700 pl-2"><button onclick="sendCtrl('c')" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" title="Ctrl+C 中断">⌃C</button><button onclick="sendCtrl('d')" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" title="Ctrl+D 退出">⌃D</button><button onclick="sendKey('\t')" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" title="Tab 补全">Tab</button></div></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Right Server Panel -->
|
||||
@@ -269,7 +269,53 @@
|
||||
} catch(e){ console.warn('Server info load error:', e); }
|
||||
})();
|
||||
|
||||
// ── Init ──
|
||||
// ── Command Input Bar ──
|
||||
const cmdInput = document.getElementById('cmdInput');
|
||||
let cmdHistory = [];
|
||||
let cmdHistoryIdx = -1;
|
||||
|
||||
cmdInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
sendCmd();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (cmdHistoryIdx < cmdHistory.length - 1) {
|
||||
cmdHistoryIdx++;
|
||||
cmdInput.value = cmdHistory[cmdHistory.length - 1 - cmdHistoryIdx];
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (cmdHistoryIdx > 0) {
|
||||
cmdHistoryIdx--;
|
||||
cmdInput.value = cmdHistory[cmdHistory.length - 1 - cmdHistoryIdx];
|
||||
} else {
|
||||
cmdHistoryIdx = -1;
|
||||
cmdInput.value = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function sendCmd() {
|
||||
const cmd = cmdInput.value;
|
||||
if (!cmd || ws?.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify({ type: 'DATA', data: cmd + '\r' }));
|
||||
cmdHistory.push(cmd);
|
||||
cmdHistoryIdx = -1;
|
||||
cmdInput.value = '';
|
||||
}
|
||||
|
||||
function sendCtrl(key) {
|
||||
if (ws?.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify({ type: 'DATA', data: String.fromCharCode(key.toLowerCase().charCodeAt(0) - 96) }));
|
||||
}
|
||||
|
||||
function sendKey(key) {
|
||||
if (ws?.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify({ type: 'DATA', data: key }));
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
loadServerList();
|
||||
connect();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user