@hyzyn/dsh-tty 0.16.2 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -1
- package/client.js +124 -37
- package/lib/index.d.ts +9 -0
- package/lib/index.js +217 -1
- package/lib/index.js.map +1 -1
- package/lib/ssh.d.ts +11 -0
- package/lib/ssh.js +63 -0
- package/lib/ssh.js.map +1 -1
- package/lib/stats.d.ts +168 -0
- package/lib/stats.js +882 -0
- package/lib/stats.js.map +1 -0
- package/package.json +2 -2
package/lib/index.d.ts
CHANGED
|
@@ -31,6 +31,13 @@
|
|
|
31
31
|
* S→C {t:'exit', sid, code, signal} PTY 退出事实(恰好一次)
|
|
32
32
|
* S→C {t:'error', sid?, m} 错误
|
|
33
33
|
* S→C {t:'sessions', list} 会话快照(attachable=true 表示前连接已断、可 attach)
|
|
34
|
+
* C→S {t:'statsOn'|'statsOff', sid} 订阅/退订该会话的服务器状态条(0.17.0,
|
|
35
|
+
* 按标签可见性驱动:首个 statsOn 才启动采集,
|
|
36
|
+
* 退订清零即停表并关远端 exec channel)
|
|
37
|
+
* S→C {t:'stats', sid, stats} 资源指标帧(0.17.0):cpuPct/cores/memUsed/
|
|
38
|
+
* memTotal/memPct/diskUsed/diskTotal/diskPct/
|
|
39
|
+
* uptimeSec/tcpConns/rxRate/txRate/tempC;缺失
|
|
40
|
+
* 即省略(best-effort),字节类为 bytes、速率为 B/s
|
|
34
41
|
* 省略 sid 时按「该连接唯一会话」路由;连接上存在 0 或多个会话时省略 sid 报错。
|
|
35
42
|
* 旧脚本(spawn 不带 sid)自动兼容:宿主生成 sid,响应帧多带 sid 字段。
|
|
36
43
|
*
|
|
@@ -95,6 +102,8 @@ export interface Config {
|
|
|
95
102
|
endOnPageClose?: boolean;
|
|
96
103
|
/** SFTP 传输限制(0 = 不限)。 */
|
|
97
104
|
sftpLimits?: Partial<SftpLimits>;
|
|
105
|
+
/** 服务器状态条(0.17.0):是否采集并推送会话资源指标(CPU/内存/磁盘/uptime/TCP/网速/温度)。默认开。 */
|
|
106
|
+
statsEnabled?: boolean;
|
|
98
107
|
/** 内部状态:SSH 持久会话名(远程 tmux 托管,本机 socket 清单看不到,随 settings 留存供新窗口恢复确认)。 */
|
|
99
108
|
persistSessions?: Array<{
|
|
100
109
|
tmuxName: string;
|
package/lib/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import { parseKnownHosts } from './known-hosts.js';
|
|
|
22
22
|
import { TunnelManager } from './tunnels.js';
|
|
23
23
|
import { SftpManager } from './sftp.js';
|
|
24
24
|
import { buildTmuxSpawnPlan, ensureTmuxAssets, killTmuxSession, listTmuxSessions, probeTmux, refreshTmuxClient, sanitizePersistName } from './tmux.js';
|
|
25
|
+
import { buildRemoteStatsCommand, buildWindowsStatsCommand, hasStatsData, localStatsSampler, parseStatsLine } from './stats.js';
|
|
25
26
|
/** SFTP 传输限制默认值。 */
|
|
26
27
|
const DEFAULT_SFTP_LIMITS = { maxDownloadMb: 1024, maxUploadMb: 2048, maxUploadFiles: 1000 };
|
|
27
28
|
const SSH_HOST_SCHEMA = z.object({
|
|
@@ -70,6 +71,7 @@ const TTY_SETTINGS_SCHEMA = z.object({
|
|
|
70
71
|
sftpStyle: z.union([z.const('dialog'), z.const('dual')]).default('dialog'),
|
|
71
72
|
persistence: z.union([z.const('off'), z.const('tmux')]).default('off'),
|
|
72
73
|
endOnPageClose: z.boolean().default(false),
|
|
74
|
+
statsEnabled: z.boolean().default(true),
|
|
73
75
|
sftpLimits: z.object({
|
|
74
76
|
maxDownloadMb: z.natural().max(1024 * 1024).default(1024),
|
|
75
77
|
maxUploadMb: z.natural().max(1024 * 1024).default(2048),
|
|
@@ -115,6 +117,8 @@ const BUFFER_CAP = 256 * 1024;
|
|
|
115
117
|
const TERM_RE = /^[A-Za-z0-9_.+-]+$/;
|
|
116
118
|
/** 孤儿会话回收器的扫描间隔。 */
|
|
117
119
|
const REAPER_INTERVAL_MS = 10_000;
|
|
120
|
+
/** 服务器状态条的采集/推送间隔(mvp 固定 1s,不做配置项)。 */
|
|
121
|
+
const STATS_INTERVAL_MS = 1000;
|
|
118
122
|
const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎),像本地终端一样操作远程主机。设置卡片开启「会话持久化(tmux)」后,新开的本地/SSH 标签默认由 tmux server 托管(宿主重启/断线超时后重开即恢复现场),长任务建议在持久化开启时运行。长驻进程(dev server、watch、交互式程序)应引导用户到终端面板里运行,不要在 bash 工具里挂起等待;用户提到「开个终端 / 在终端里跑 / SSH 到某台机器」时引导其打开该面板。agent 侧配套工具:tty_list 列出活跃终端会话(含 SSH 的 target 与实时 cwd),tty_capture 读取近期输出(默认清洗 ANSI;last:true 拿「上一条命令」的输出+退出码),tty_screen 读取当前可见屏幕(可读懂 vim/htop 等 TUI),tty_expect 用正则等待输出中的就绪信号(如 dev server URL、构建完成),tty_send 发送按键,tunnel_list 列出端口转发隧道状态——操作会实时显示在用户终端里。SFTP 文件传输:面板内可对 SSH 连接簿条目(或 SSH 连接对话框当前填写的信息)打开文件浏览(上传/下载/建目录/重命名/删除),传输期间进度条右侧 ✕ 可取消(半截文件自动清理);agent 配套 sftp_list 列远程目录、sftp_tree 递归看目录结构、sftp_read 读远程文本文件(≤1MB)、sftp_write 写远程文本文件(≤1MB,可追加)、sftp_mkdir 建目录(parents 可逐级补齐)、sftp_rename 重命名/移动、sftp_remove 删除(目录需 recursive),book 参数为连接簿条目名。端口转发:连接簿条目可配本地/远程隧道(如把远程数据库映射到本地端口),宿主自动保活重连,用户提到「转发端口 / 访问远程库」时引导其到终端面板设置卡片配置。推荐流程:tty_send 启动长任务 → tty_expect 等就绪标记 → tty_capture{last:true} 拿结果。';
|
|
119
123
|
/** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
|
|
120
124
|
function wrapLocalPty(handle) {
|
|
@@ -171,6 +175,8 @@ class LiveConfig {
|
|
|
171
175
|
persistence;
|
|
172
176
|
/** 页面断开且保活期结束时是否结束 tmux 持久会话(默认 false = 留存)。 */
|
|
173
177
|
endOnPageClose;
|
|
178
|
+
/** 服务器状态条:是否采集并推送会话资源指标(默认 true)。 */
|
|
179
|
+
statsEnabled;
|
|
174
180
|
/** SSH 持久会话名(远程 tmux 托管;本机 socket 清单看不到,随 settings 留存)。 */
|
|
175
181
|
persistSessions;
|
|
176
182
|
/** SFTP 传输限制(客户端浏览器侧执行)。 */
|
|
@@ -187,6 +193,8 @@ class LiveConfig {
|
|
|
187
193
|
this.tunnels = init.tunnels ?? [];
|
|
188
194
|
this.persistence = init.persistence === 'tmux' ? 'tmux' : 'off';
|
|
189
195
|
this.endOnPageClose = init.endOnPageClose === true;
|
|
196
|
+
// 只有显式 false 才关(缺省/旧配置一律视为开)
|
|
197
|
+
this.statsEnabled = init.statsEnabled !== false;
|
|
190
198
|
this.sftpLimits = sanitizeSftpLimits(init.sftpLimits);
|
|
191
199
|
this.persistSessions = init.persistSessions ?? [];
|
|
192
200
|
}
|
|
@@ -215,6 +223,8 @@ class LiveConfig {
|
|
|
215
223
|
this.persistence = partial.persistence;
|
|
216
224
|
if (typeof partial.endOnPageClose === 'boolean')
|
|
217
225
|
this.endOnPageClose = partial.endOnPageClose;
|
|
226
|
+
if (typeof partial.statsEnabled === 'boolean')
|
|
227
|
+
this.statsEnabled = partial.statsEnabled;
|
|
218
228
|
if (partial.sftpLimits !== undefined)
|
|
219
229
|
this.sftpLimits = sanitizeSftpLimits({ ...this.sftpLimits, ...partial.sftpLimits });
|
|
220
230
|
if (Array.isArray(partial.persistSessions))
|
|
@@ -701,6 +711,11 @@ class SessionManager {
|
|
|
701
711
|
attachable: session.clients.size === 0 && !session.closed,
|
|
702
712
|
}));
|
|
703
713
|
}
|
|
714
|
+
/** 遍历全部会话(状态条采集器的批量收尾等按会话维度的操作)。 */
|
|
715
|
+
forEach(fn) {
|
|
716
|
+
for (const session of this.sessions.values())
|
|
717
|
+
fn(session);
|
|
718
|
+
}
|
|
704
719
|
/** 按 tmux 持久会话名查找存活会话(跨窗口共享用);不存在/已关闭返回 undefined。 */
|
|
705
720
|
findByTmuxName(tmuxName) {
|
|
706
721
|
for (const session of this.sessions.values()) {
|
|
@@ -774,6 +789,8 @@ class TtyServer {
|
|
|
774
789
|
pendingTmux = new Map();
|
|
775
790
|
/** WS 闸门(插件禁用时关闭):拒绝新升级 + 断开存量连接。 */
|
|
776
791
|
wsGateOpen = true;
|
|
792
|
+
/** 服务器状态条总开关(配置热生效;关闭时停掉全部采集,重开按订阅恢复)。 */
|
|
793
|
+
statsOn = true;
|
|
777
794
|
constructor(ctx, sessions, options, hostKeyStore,
|
|
778
795
|
/** SSH 持久会话名留存回调(apply 闭包实现,settings 落盘)。 */
|
|
779
796
|
trackPersist) {
|
|
@@ -782,6 +799,7 @@ class TtyServer {
|
|
|
782
799
|
this.options = options;
|
|
783
800
|
this.hostKeyStore = hostKeyStore;
|
|
784
801
|
this.trackPersist = trackPersist;
|
|
802
|
+
this.statsOn = options.statsEnabled;
|
|
785
803
|
this.wss.on('connection', (ws) => this.onConnection(ws));
|
|
786
804
|
}
|
|
787
805
|
/**
|
|
@@ -795,6 +813,8 @@ class TtyServer {
|
|
|
795
813
|
this.wsGateOpen = open;
|
|
796
814
|
if (open)
|
|
797
815
|
return;
|
|
816
|
+
// 禁用:采集器先停(远端 exec channel / 本地定时器都不该活过闸门)
|
|
817
|
+
this.stopAllStats();
|
|
798
818
|
for (const ws of this.wss.clients) {
|
|
799
819
|
try {
|
|
800
820
|
ws.close(1001, 'dsh-tty disabled');
|
|
@@ -804,6 +824,162 @@ class TtyServer {
|
|
|
804
824
|
}
|
|
805
825
|
}
|
|
806
826
|
}
|
|
827
|
+
/* --------------------------- 服务器状态条(0.17.0) --------------------------- */
|
|
828
|
+
/**
|
|
829
|
+
* 配置热生效:关闭时停掉全部采集(本地定时器 + 远端 exec channel);重新打开
|
|
830
|
+
* 时对**仍有订阅**的会话懒启动。订阅集合刻意不清——客户端只在标签可见性变化
|
|
831
|
+
* 时发 statsOn/statsOff,开关来回切不该要求它重发。
|
|
832
|
+
*/
|
|
833
|
+
setStatsEnabled(enabled) {
|
|
834
|
+
this.statsOn = enabled;
|
|
835
|
+
if (!enabled) {
|
|
836
|
+
this.stopAllStats();
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
this.sessions.forEach((session) => {
|
|
840
|
+
if (session.statsSubs.size > 0)
|
|
841
|
+
this.startStats(session);
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
/** 订阅/退订(tab 可见性驱动):退到 0 即停表,任何路径都不会让采集器空转。 */
|
|
845
|
+
setStatsSub(session, clientSid, on) {
|
|
846
|
+
if (on) {
|
|
847
|
+
session.statsSubs.add(clientSid);
|
|
848
|
+
this.startStats(session);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
session.statsSubs.delete(clientSid);
|
|
852
|
+
if (session.statsSubs.size === 0)
|
|
853
|
+
this.stopStats(session);
|
|
854
|
+
}
|
|
855
|
+
/** 清掉指向已解绑客户端(WS 关闭 / 标签换 sid 重绑)的订阅,防采集器永不收尾。 */
|
|
856
|
+
pruneStatsSubs(session) {
|
|
857
|
+
for (const clientSid of [...session.statsSubs]) {
|
|
858
|
+
if (!session.clients.has(clientSid))
|
|
859
|
+
this.setStatsSub(session, clientSid, false);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* 懒启动采集(首个 statsOn 才起)。两条路径产出同形状的帧:
|
|
864
|
+
* - 本地:宿主进程就是那台机器,1s 定时器 + 进程级共享采样器(多个本地标签
|
|
865
|
+
* 共享一次 df/netstat);
|
|
866
|
+
* - SSH:远端 sh + awk 常驻循环,每秒一行 JSON 走**非 PTY** exec channel;
|
|
867
|
+
* 速率类由远端算好,宿主只解析 + 清洗。
|
|
868
|
+
* 任何失败都静默停表并置 statsFailed(粘性,避免每秒重启):前端靠「无数据」
|
|
869
|
+
* 隐藏状态条,PTY 数据路径与终端体验完全不受影响。
|
|
870
|
+
*/
|
|
871
|
+
startStats(session) {
|
|
872
|
+
if (!this.statsOn || session.closed || session.stats !== null || session.statsFailed)
|
|
873
|
+
return;
|
|
874
|
+
if (session.kind === 'local') {
|
|
875
|
+
const sampler = localStatsSampler();
|
|
876
|
+
let busy = false;
|
|
877
|
+
let timer = null;
|
|
878
|
+
const collector = {
|
|
879
|
+
stop: () => {
|
|
880
|
+
if (timer !== null)
|
|
881
|
+
clearInterval(timer);
|
|
882
|
+
timer = null;
|
|
883
|
+
},
|
|
884
|
+
};
|
|
885
|
+
const tick = () => {
|
|
886
|
+
if (session.closed) {
|
|
887
|
+
collector.stop(); // 会话已回收:定时器自收尾,不依赖外部钩子是否齐全
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (busy)
|
|
891
|
+
return; // 上一拍还没回来(df 卡住)就跳过,不堆积
|
|
892
|
+
busy = true;
|
|
893
|
+
void sampler.sample().then((frame) => {
|
|
894
|
+
busy = false;
|
|
895
|
+
if (session.stats !== collector || session.closed)
|
|
896
|
+
return;
|
|
897
|
+
if (hasStatsData(frame))
|
|
898
|
+
this.sendStats(session, frame);
|
|
899
|
+
});
|
|
900
|
+
};
|
|
901
|
+
timer = setInterval(tick, STATS_INTERVAL_MS);
|
|
902
|
+
timer.unref?.();
|
|
903
|
+
session.stats = collector;
|
|
904
|
+
tick(); // 首个订阅立刻出值,不让状态条空一个周期
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
const statsExec = session.handle.statsExec;
|
|
908
|
+
if (statsExec === undefined) {
|
|
909
|
+
session.statsFailed = true;
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
let stopped = false;
|
|
913
|
+
/** 本次采集是否读到过合法帧——决定「这一跳结束」算失败还是算远端自己收摊。 */
|
|
914
|
+
let sawFrame = false;
|
|
915
|
+
let handle = null;
|
|
916
|
+
const collector = {
|
|
917
|
+
stop: () => {
|
|
918
|
+
stopped = true;
|
|
919
|
+
handle?.stop();
|
|
920
|
+
},
|
|
921
|
+
};
|
|
922
|
+
/**
|
|
923
|
+
* 起一跳采集。远端平台事先不知道,所以先跑 POSIX 脚本;若 channel 在**一帧
|
|
924
|
+
* 数据都没读过**的情况下结束,说明对端不是 POSIX 平台(Windows 上 cmd.exe /
|
|
925
|
+
* PowerShell 根本解析不了 sh -c 脚本),再换 PowerShell 版(-EncodedCommand,
|
|
926
|
+
* 同帧形状)试一次。两跳都失败(如 macOS/BSD 远端:既无 /proc 也无 PowerShell)
|
|
927
|
+
* 才置粘性失败位,前端按「无数据」隐藏状态条。
|
|
928
|
+
*/
|
|
929
|
+
const attempt = (command, powershell) => {
|
|
930
|
+
handle = statsExec(command, (line) => {
|
|
931
|
+
if (stopped || session.closed)
|
|
932
|
+
return;
|
|
933
|
+
const frame = parseStatsLine(line);
|
|
934
|
+
if (frame === null)
|
|
935
|
+
return;
|
|
936
|
+
sawFrame = true;
|
|
937
|
+
if (hasStatsData(frame))
|
|
938
|
+
this.sendStats(session, frame);
|
|
939
|
+
}, () => {
|
|
940
|
+
if (stopped)
|
|
941
|
+
return; // 我们自己停的,不算失败
|
|
942
|
+
if (!sawFrame && !powershell) {
|
|
943
|
+
attempt(buildWindowsStatsCommand(), true);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
// 读过帧 = 远端采集进程自己停了;一帧未读 = 彻底失败——都停表并置粘性失败位
|
|
947
|
+
session.statsFailed = true;
|
|
948
|
+
session.stats = null;
|
|
949
|
+
collector.stop();
|
|
950
|
+
});
|
|
951
|
+
};
|
|
952
|
+
// 先登记再起采集:同步失败(conn.exec 直接抛错)也走同一套收尾
|
|
953
|
+
session.stats = collector;
|
|
954
|
+
attempt(buildRemoteStatsCommand(), false);
|
|
955
|
+
// 双跳同步失败时 collector.stop() 已把当时在手的句柄停掉;conn.exec 直接抛错的
|
|
956
|
+
// 那一跳压根没建 channel,句柄是惰性的,不需要额外收尾。
|
|
957
|
+
}
|
|
958
|
+
/** 停表(幂等):订阅清零 / 会话结束 / 插件禁用 / 配置关闭都走它。 */
|
|
959
|
+
stopStats(session) {
|
|
960
|
+
const collector = session.stats;
|
|
961
|
+
session.stats = null;
|
|
962
|
+
if (collector !== null)
|
|
963
|
+
collector.stop();
|
|
964
|
+
}
|
|
965
|
+
stopAllStats() {
|
|
966
|
+
this.sessions.forEach((session) => this.stopStats(session));
|
|
967
|
+
}
|
|
968
|
+
/** 帧只发给订阅了该会话的客户端(按各客户端自己的 sid 寻址,跨窗口共享也成立)。 */
|
|
969
|
+
sendStats(session, frame) {
|
|
970
|
+
for (const clientSid of session.statsSubs) {
|
|
971
|
+
const ws = session.clients.get(clientSid);
|
|
972
|
+
if (ws === undefined)
|
|
973
|
+
continue;
|
|
974
|
+
try {
|
|
975
|
+
send(ws, { t: 'stats', sid: clientSid, stats: frame });
|
|
976
|
+
}
|
|
977
|
+
catch {
|
|
978
|
+
// readyState 检查与 send 之间对端可能刚关:不 try 的话异常会落在 ws 事件
|
|
979
|
+
// 回调或采样 promise 里(未处理异常/未处理拒绝 → 宿主进程直接退出)
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
807
983
|
/** registerUpgrade 的 handler(loopback 围栏 + ws 握手)。 */
|
|
808
984
|
handleUpgrade(req, socket, head) {
|
|
809
985
|
if (!isLoopbackUpgrade(req)) {
|
|
@@ -829,6 +1005,8 @@ class TtyServer {
|
|
|
829
1005
|
return;
|
|
830
1006
|
// 解绑本连接的客户端;其余窗口仍绑定着(跨连接共享)时会话继续在线
|
|
831
1007
|
session.clients.delete(clientSid);
|
|
1008
|
+
// WS 关闭/转孤儿:该端的 stats 订阅一并解绑(退到 0 就停表关 channel)
|
|
1009
|
+
this.pruneStatsSubs(session);
|
|
832
1010
|
if (session.clients.size > 0) {
|
|
833
1011
|
this.flushPendingOutput(session);
|
|
834
1012
|
return;
|
|
@@ -888,6 +1066,7 @@ class TtyServer {
|
|
|
888
1066
|
rebindClient(session, sid, ws, local) {
|
|
889
1067
|
session.clients.set(sid, ws);
|
|
890
1068
|
session.orphanedAt = null;
|
|
1069
|
+
this.pruneStatsSubs(session);
|
|
891
1070
|
if (session.paused) {
|
|
892
1071
|
session.paused = false;
|
|
893
1072
|
try {
|
|
@@ -929,6 +1108,9 @@ class TtyServer {
|
|
|
929
1108
|
* 会话会留在 tmux server 上);2.5s 兜底 forceKill 防收尾悬挂。
|
|
930
1109
|
*/
|
|
931
1110
|
killSessionNow(session) {
|
|
1111
|
+
// 采集器先收(远端 exec channel 与本地定时器都不该活过会话)
|
|
1112
|
+
session.statsSubs.clear();
|
|
1113
|
+
this.stopStats(session);
|
|
932
1114
|
this.sessions.retire(session);
|
|
933
1115
|
const teardown = session.handle.tmuxTeardown;
|
|
934
1116
|
if (teardown !== undefined) {
|
|
@@ -1059,6 +1241,9 @@ class TtyServer {
|
|
|
1059
1241
|
pendingOutput: '',
|
|
1060
1242
|
flushTimer: null,
|
|
1061
1243
|
tmuxName,
|
|
1244
|
+
statsSubs: new Set(),
|
|
1245
|
+
stats: null,
|
|
1246
|
+
statsFailed: false,
|
|
1062
1247
|
};
|
|
1063
1248
|
local.set(sid, next);
|
|
1064
1249
|
this.sessions.add(next);
|
|
@@ -1165,6 +1350,9 @@ class TtyServer {
|
|
|
1165
1350
|
pendingOutput: '',
|
|
1166
1351
|
flushTimer: null,
|
|
1167
1352
|
tmuxName,
|
|
1353
|
+
statsSubs: new Set(),
|
|
1354
|
+
stats: null,
|
|
1355
|
+
statsFailed: false,
|
|
1168
1356
|
};
|
|
1169
1357
|
local.set(sid, next);
|
|
1170
1358
|
this.sessions.add(next);
|
|
@@ -1302,11 +1490,27 @@ class TtyServer {
|
|
|
1302
1490
|
send(ws, { t: 'data', sid: raw, d: session.buffer });
|
|
1303
1491
|
}
|
|
1304
1492
|
}
|
|
1493
|
+
else if (msg.t === 'statsOn' || msg.t === 'statsOff') {
|
|
1494
|
+
this.handleStatsFrame(ws, msg, local);
|
|
1495
|
+
}
|
|
1305
1496
|
}
|
|
1306
1497
|
catch (error) {
|
|
1307
1498
|
send(ws, { t: 'error', m: error instanceof Error ? error.message : String(error) });
|
|
1308
1499
|
}
|
|
1309
1500
|
}
|
|
1501
|
+
/**
|
|
1502
|
+
* 服务器状态条订阅(0.17.0):按「标签可见性」驱动——只有可见标签才发
|
|
1503
|
+
* statsOn。未知 sid(客户端竞态)静默忽略,不回错误帧。
|
|
1504
|
+
*/
|
|
1505
|
+
handleStatsFrame(ws, msg, local) {
|
|
1506
|
+
const resolved = this.resolveSid(ws, msg, local);
|
|
1507
|
+
if (resolved === undefined || 'unknown' in resolved)
|
|
1508
|
+
return;
|
|
1509
|
+
const session = local.get(resolved.sid);
|
|
1510
|
+
if (session === undefined || session.closed)
|
|
1511
|
+
return;
|
|
1512
|
+
this.setStatsSub(session, resolved.sid, msg.t === 'statsOn');
|
|
1513
|
+
}
|
|
1310
1514
|
/** 会话退出事实 → exit 帧(恰好一次;本地 PTY 与 SSH 共用)。 */
|
|
1311
1515
|
watchDone(session, local) {
|
|
1312
1516
|
session.handle.done.then((outcome) => {
|
|
@@ -1316,6 +1520,8 @@ class TtyServer {
|
|
|
1316
1520
|
return;
|
|
1317
1521
|
session.exitSent = true;
|
|
1318
1522
|
session.closed = true;
|
|
1523
|
+
session.statsSubs.clear();
|
|
1524
|
+
this.stopStats(session);
|
|
1319
1525
|
local.delete(session.id);
|
|
1320
1526
|
this.sessions.remove(session.id);
|
|
1321
1527
|
if (session.kind === 'ssh' && session.tmuxName !== null)
|
|
@@ -1650,6 +1856,7 @@ const plugin = definePlugin({
|
|
|
1650
1856
|
tunnels: Array.isArray(config?.tunnels) ? config.tunnels : [],
|
|
1651
1857
|
persistence: config?.persistence === 'tmux' ? 'tmux' : 'off',
|
|
1652
1858
|
endOnPageClose: config?.endOnPageClose === true,
|
|
1859
|
+
statsEnabled: config?.statsEnabled !== false,
|
|
1653
1860
|
sftpLimits: sanitizeSftpLimits(config?.sftpLimits),
|
|
1654
1861
|
persistSessions: sanitizePersistSessions(config?.persistSessions) ?? [],
|
|
1655
1862
|
});
|
|
@@ -1712,6 +1919,7 @@ const plugin = definePlugin({
|
|
|
1712
1919
|
sftpStyle: stateRef.sftpStyle,
|
|
1713
1920
|
persistence: live.persistence,
|
|
1714
1921
|
endOnPageClose: live.endOnPageClose,
|
|
1922
|
+
statsEnabled: live.statsEnabled,
|
|
1715
1923
|
sftpLimits: live.sftpLimits,
|
|
1716
1924
|
toolsRegistered: stateRef.toolsRegistered,
|
|
1717
1925
|
});
|
|
@@ -1729,6 +1937,7 @@ const plugin = definePlugin({
|
|
|
1729
1937
|
tunnels: sanitizeTunnels(section.tunnels),
|
|
1730
1938
|
persistence: section.persistence === 'tmux' || section.persistence === 'off' ? section.persistence : undefined,
|
|
1731
1939
|
endOnPageClose: typeof section.endOnPageClose === 'boolean' ? section.endOnPageClose : undefined,
|
|
1940
|
+
statsEnabled: typeof section.statsEnabled === 'boolean' ? section.statsEnabled : undefined,
|
|
1732
1941
|
sftpLimits: typeof section.sftpLimits === 'object' && section.sftpLimits !== null ? section.sftpLimits : undefined,
|
|
1733
1942
|
persistSessions: sanitizePersistSessions(section.persistSessions),
|
|
1734
1943
|
});
|
|
@@ -1750,12 +1959,14 @@ const plugin = definePlugin({
|
|
|
1750
1959
|
refreshToolsHook();
|
|
1751
1960
|
refreshAnnouncementHook();
|
|
1752
1961
|
server.setWsGate(stateRef.enabled);
|
|
1962
|
+
// 状态条开关热生效:关的时候停掉全部采集(本地定时器 + 远端 exec channel)
|
|
1963
|
+
server.setStatsEnabled(live.statsEnabled);
|
|
1753
1964
|
console.log(`[dsh-tty] config applied (shell=${live.shell}, term=${live.term}, cwd=${live.cwd}, maxSessions=${sessions.limitValue}, sshHosts=${live.sshHosts.length}, enabled=${String(stateRef.enabled)})`);
|
|
1754
1965
|
};
|
|
1755
1966
|
/** 校验 HTTP POST 的配置体;返回规范化补丁或错误信息。 */
|
|
1756
1967
|
const normalizePatch = (input) => {
|
|
1757
1968
|
const patch = {};
|
|
1758
|
-
const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle', 'persistence', 'endOnPageClose', 'sftpLimits']);
|
|
1969
|
+
const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle', 'persistence', 'endOnPageClose', 'statsEnabled', 'sftpLimits']);
|
|
1759
1970
|
for (const key of Object.keys(input)) {
|
|
1760
1971
|
if (!known.has(key))
|
|
1761
1972
|
return { error: '未知配置项: ' + key };
|
|
@@ -1802,6 +2013,11 @@ const plugin = definePlugin({
|
|
|
1802
2013
|
return { error: 'endOnPageClose 必须是布尔值' };
|
|
1803
2014
|
patch.endOnPageClose = input.endOnPageClose;
|
|
1804
2015
|
}
|
|
2016
|
+
if (input.statsEnabled !== undefined) {
|
|
2017
|
+
if (typeof input.statsEnabled !== 'boolean')
|
|
2018
|
+
return { error: 'statsEnabled 必须是布尔值' };
|
|
2019
|
+
patch.statsEnabled = input.statsEnabled;
|
|
2020
|
+
}
|
|
1805
2021
|
for (const key of ['shell', 'term', 'colorTerm']) {
|
|
1806
2022
|
if (input[key] === undefined)
|
|
1807
2023
|
continue;
|