为什么要自己写一个
我的日常是这样的:几台服务器要连着看日志,改配置要开 SFTP 传文件,数据排查又要切到 Navicat 连数据库。三个工具、十几个窗口、密码到处存,换台电脑还得重新配一遍。于是有了 OnlineSSH:一个跑在浏览器里的运维工作台。打开网页就能连服务器执行命令、传文件、看状态,顺手还能连数据库查数据、和别人共享一个终端排查线上问题。它现在是这个规模:约 2 万行代码(前端 1.5 万 + 后端 4600),36 个 Vue 组件、24 个后端模块,配了 76 个浏览器端验证脚本。
它都能做什么
-
SSH 终端:WebSocket 透传
ssh2,xterm.js 渲染,多标签页,右侧可挂 SFTP / 监控 / 协作 / 快捷命令 / AI 对话五个面板。连接失败会降级成演示终端,不会让整页卡死。 -
设备管理:设备增删改查、真实 SSH 握手测延迟、30 秒静默轮询在线状态、IP 脱敏显示(
192.***.***.40)。 -
数据库管理:MySQL / PostgreSQL / MongoDB / Redis / SQLite 五种引擎真实连接,不是演示数据。SQLite 还支持三种接入方式(服务器本地路径 / URL 自动下载 / 文件上传,按用户隔离存储 + 文件头校验 + 7 天自动清理)。查询统一有
LIMIT 1000保护,结果分页、列宽自适应。 -
协作与配置:共享终端会话(
/join/:id共同操作,可踢人)、中英文双语、Dark/Light 主题 + 自定义主题色、设置按用户存后端换设备自动带回。
整体架构
前后端分离,但刻意做成单进程可跑通——后端一个 node src/index.js 就能起,
前端 Vite 构建后是纯静态资源,部署门槛很低。
架构上有两个我自己比较满意的决定:
一是存储层做成可切换的双模式。 开发/小规模部署用「内存 SQLite + JSON 快照落盘」,
零依赖、重启自动恢复;上生产改成 DB_DRIVER=mysql 就切到 MySQL 并自动建库建表。
同一套业务代码不用改。
二是所有数据库驱动都抽象成一个统一入口。 这一点在下面第二个坑里救了命。
坑一:浏览器里的终端为什么"不跟手"
xterm.js 本身很好用,但浏览器终端天生比本地终端"慢半拍"——你敲一个字母,
要等它传到服务器、服务器回显、再传回来,屏幕上才出现这个字符。跨地域链路下
(我这边到香港节点 RTT 200ms 上下),打字会有明显的"粘滞感"。
解法是本地即时回显(typeahead):击键立刻上屏,不等服务端回包;等远端回显到了,
再按前缀把重复的部分剥掉。手感立刻接近本地终端。
听起来很简单,但真正麻烦的是出错的时候:
- 远端回显到达时,怎么判断它是"我预测过的那部分"还是"意料之外的内容"?只能按前缀比对剥离;
- 用户按了退格、方向键怎么办?本地预测这时候几乎一定会错——用户删掉
hela的a
准备改成help,本地立刻擦掉那个字符;但服务端可能还没收到退格,它把
"hela + 删除 + help" 的回显批量发回来,屏幕上就会残留he; - 用户敲的可能是
vi、top这类全屏程序,它们会切到 alternate screen,
本地预测的字符会漏到屏幕上,必须一进全屏就关掉预测。
最后我引入了一个 desynced(失同步)标志,规则是:
| 场景 | 行为 |
|---|---|
| 普通字母输入 | 本地预测上屏,远端回显按前缀去重 |
| 退格 / 方向键等编辑键 | 保留已打印内容、不擦屏,并置 desynced |
desynced 期间的前向字符 |
不做本地预测,全部交回服务端真实回显 |
| 回车 | 复位 desynced |
| 进入 alternate screen | 关闭本地预测 |
一句话总结:能预测的时候预测,一旦拿不准就老实等服务器。 "跟手"是体验,
"不出错"是底线,两者冲突时后者优先。
坑二:一次只在生产环境炸的依赖事故
后端要支持 5 种数据库,用的都是官方驱动:mysql2 / pg / mongodb / ioredis。
为了启动零开销,它们全部惰性加载——没配连接就不 import。
事故现场是这样的:
用户连接 Redis → 界面报错:
Cannot find package 'ioredis' imported from /www/.../src/routes/databases.js
本地一直好好的。原因很朴素:ioredis 装在开发机的 node_modules 里,
但 server/package.json 的 dependencies 漏了声明。
而生产部署执行的是 npm install --omit=dev——package.json 没写的包,
npm 根本不会装。于是"本地能用"和"线上能用"之间出现了裂缝。
补声明是一分钟的事,但顺着查下去发现同构问题还有一串:
- MySQL / PostgreSQL / MongoDB 的查询路径也漏在
try外面——缺包错误绕过
catch 直接冒泡成 500,把原始英文报错透到界面上(和 Redis 是同一个病); node:sqlite是静态 import——它是 Node 内置模块、有 22.5+ 的版本门槛,
版本不够时模块图解析阶段就失败,整个后端起不来,而生产用 MySQL 时本不该受它牵连;- 全局 500 兜底会把
err.message原样回给前端——英文技术细节漏到界面的第二道口子。
最后的处理方式,我总结成了一套可以复用的约定:
| 机制 | 做法 |
|---|---|
| 统一加载口 | loadMysql() / loadPg() / loadMongo() / loadRedis() / requireSqlite(),路由里不直接 import 驱动 |
| 缺包中文降级 | 识别 ERR_MODULE_NOT_FOUND → "后端缺少 MySQL 驱动(mysql2),请执行 npm install 后重启服务";其余错误原样抛出,不掩盖真实故障 |
| 调用纪律 | loadXxx() 必须写在 try 内部,否则缺包错误绕过 catch |
| 启动自检 | 启动日志打印 数据库驱动: MySQL=可用 PostgreSQL=可用 …,只查模块路径不加载模块本体,缺哪个一眼看见 |
| 500 过滤 | 只拦技术细节(包缺失、node_modules 路径、.js:行号),业务错误照原样返回 |
再配两个自查脚本,把"约定"变成"能自动验证的东西":
npm run deps:audit # 扫 src/ 全部裸模块引用比对 package.json,漏声明直接 exit 1
npm run verify:drivers # 临时隐藏 node_modules/<pkg>,真实模拟"生产缺包",断言降级为中文(41 项)
verify:drivers 的做法值得说一下:它把 node_modules/mysql2 临时改名成
mysql2.verify-hidden,再先发一次真实 HTTP 请求——顺序不能反,因为 ESM 会缓存
已加载的模块,先成功请求过一次就永远测不出缺包了。断言返回 HTTP 400 + 中文提示,
然后还原包再请求一次,确认变回真实的连接错误(证明没有把真实故障也一起吞掉)。
关于测试:76 个"浏览器验证脚本"
这个项目我没写多少传统单测(前端 7 个测试文件、50 条用例),但写了 76 个 Playwright 脚本,每个对应一个具体的 UI 行为:
- 侧边栏收缩后,按钮的点击区域是否和菜单项一致(
verify-sidebar-collapse); - 切换 5 种主题色后,分割器图标与分割线的颜色对不对(
verify-splitter-theme); - SQL 结果翻页后,表头与列宽是否还对得上(
verify-sql-pager/verify-sql-table); - 侧栏拖拽调宽后刷新页面,宽度有没有持久化(
verify-side-resize); - 移动端 8 种视口下有没有元素溢出(
responsive-8-viewports); - 协作会话里踢掉某人后,对面的界面有没有正确关闭(
verify-collab-kick)。
这些脚本会真实启动浏览器、登录、操作,最后截图 + 断言。对一个交互密集的项目来说,这比单测更能抓住问题——很多 bug 只在"拖了一下又切了个标签再刷新"这种组合下才出现。
// ✗ 空转:!!' ' 也是 true,两种实现下都通过
expect(hasAuthCode).toBe(true)
// ✓ 有区分度:在"全新未配置"状态下断言仍未配置,
// 只有"空值被写进库"时才会变成 true —— 这才抓得住 bug
expect(hasAuthCode).toBe(false)
部署
生产环境是一台 2C4G 的香港节点,PM2 守护进程,Nginx 反代 HTTPS + WebSocket,数据库切到 MySQL。前端改动只需要上传构建产物覆盖,后端改动按文件覆盖 + pm2 restart。
一些复盘
"本地能跑"是最危险的信号。 依赖事故的根源不是技术难度,而是开发环境和生产环境的
结构性差异。这类问题只能靠"把环境差异写进自动化验证"来防,靠人肉记住是记不住的。
体验类需求要定义好"退化策略"。 本地回显追求跟手,但拿不准时必须退回服务端真实回显。
任何"为了让体验更好而提前做假设"的功能,都要先想清楚假设不成立时怎么办。
约定要能被脚本验证,否则等于没有约定。 我项目里所有的"必须这样做"
(驱动走统一入口、敏感值不回显、设置不能回写默认值)最后都收敛成了一个断言脚本。
后续想做的
- 终端会话录制与回放(排查线上问题时很有用)
- 数据库操作的审计日志(谁在什么时候执行了什么 SQL)
- 移动端适配打磨(现在能看能用,但不够顺手)
- 把这套"验证脚本驱动 UI 开发"的流程单独抽出来写成模板














这一切,似未曾拥有