feat: initial commit of vps-switch core modules and desktop frontend

This commit is contained in:
weidu12123 committed 2026-10-13 16:36:22 +08:00
commit d2348d0c00
38 files changed
+19943

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
[source.crates-io]
replace-with = "rsproxy-sparse"
[source.rsproxy]
registry = "https://rsproxy.cn/crates.io-index"
[source.rsproxy-sparse]
registry = "sparse+https://rsproxy.cn/index/"
[net]
git-fetch-with-cli = true
+24
View File
@@ -0,0 +1,24 @@
# Rust
/target
**/*.rs.bk
# Tauri
/src-tauri/target
/src-tauri/gen
# Node / 前端
node_modules/
/frontend/dist
# 运行态与本地数据
/data
*.log
.env
.env.local
.playwright-mcp/
conpty.dll
OpenConsole.exe
# 系统
.DS_Store
Thumbs.db
+10
View File
@@ -0,0 +1,10 @@
# vps-switch 项目守则
每次在本仓库的工作都把这些当作不可协商:
1. 不堆提示词。本项目零模型调用,这条自动成立;若将来引入,提示词改动必须罕见、最小、有理由。
2. 不堆架构。架构不得替代、预答或冒充使用者(人或 agent)的判断;一份实现,两个调用方(UI / MCP),不做第二条路。
3. 不对 agent 隐藏上下文。工具结果带完整 stdout / stderr / exitCode / 耗时与护栏判定(why + suggestion),失败就是失败。
4. 概念数必须比 1Shell 少。新设计若引入比旧项目更多的名词、状态或层次,就是错的。不在 `docs/plan.md` 边界内的东西不进仓库;要加,先改边界那句话。
把时间用在思考上,不需要用 commentary 汇报进度。
Generated
+5462
View File
File diff suppressed because it is too large. Load diff
+41
View File
@@ -0,0 +1,41 @@
[package]
name = "vps-switch"
version = "0.1.0"
edition = "2021"
description = "Lightweight VPS manager and MCP server"
[dependencies]
tokio = { version = "1.40", features = ["full"] }
axum = { version = "0.7", features = ["ws", "macros"] }
tower-http = { version = "0.6", features = ["fs", "cors"] }
rusqlite = { version = "0.32", features = ["bundled"] }
russh = { version = "0.63.0", default-features = false, features = ["flate2", "ring", "rsa", "legacy-ed25519-pkcs8-parser"] }
russh-sftp = "3.0.1"
alacritty_terminal = "0.26.0"
portable-pty = "0.9.0"
tokio-util = { version = "0.7", features = ["io"] }
bytes = "1"
rust-embed = { version = "8.13.0", features = ["debug-embed", "mime-guess"] }
mime_guess = "2.0"
serde = { version = "1.0", features = ["derive"] }
[target.'cfg(windows)'.dependencies]
wry = "0.57.0"
tao = "0.37.1"
tray-icon = "0.26.1"
serde_json = "1.0"
aes-gcm = "0.10"
scrypt = { version = "0.11", default-features = false, features = ["std"] }
rand = "0.8"
base64 = "0.22"
uuid = { version = "1.10", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
anyhow = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
futures = "0.3"
async-trait = "0.1"
[profile.dev.package."*"]
opt-level = 2
+5
View File
@@ -0,0 +1,5 @@
# vps-switch
一个轻量级 VPS 管理工具:先把 VPS 仓库、终端、文件浏览器、文件上传下载、探针五大核心板块做到极致,最后将这些现成链路加上护栏工具化为 MCP Server。
范围、六大板块路线与技术栈见 [docs/plan.md](docs/plan.md)。不在那里的东西不进仓库。
+111
View File
@@ -0,0 +1,111 @@
# vps-switch 计划
> 2026-10-10 起,2026-10-13 按「先做好五大核心功能板块、最后将现成链路工具化为 MCP」重定主线。
> vps-switch 是 1Shell(Node + Electron,冻结在 4.7.7)的全面重构与升级;旧项目在本项目可用之前照常运行,且随时作为实测对照。
> 本文是"一页边界":不在这里的东西不进仓库。要加,先改边界那句话。
---
## 边界
**一个轻量级 VPS 管理工具:先把 VPS 仓库、终端、文件浏览器、文件上传下载、探针五大核心板块做到极致,最后将这些现成链路加上护栏工具化为 MCP Server。**
设计准则:
- 沿用 `AGENTS.md`:不堆提示词(本项目零模型调用)、不堆架构(一份底层实现,UI 与 MCP 共同复用)、不对 agent 隐藏上下文。
- **概念数必须比 1Shell 少。** 不设内置 AI 聊天、不设协议 agent 入驻、不设技能/脚本库。
- **先做好人用的本体,再做工具化。** MCP 不是另起炉灶的第二套系统,而是对前五个板块已验证底层链路的薄封装。
---
## 为什么要按这个路径重构
直接调用并复盘正在运行的 1Shell(1 台 `local` + 29 台 VPS,共 30 个目标),它的核心问题很清楚:
| 1Shell 的痛点 | 根源 | vps-switch 的解法 |
|---|---|---|
| **定位散乱、体积臃肿(80k 行)** | 第一次做项目时误打误撞,把内置 AI 对话、Claude/Codex 驻留面板、技能库、脚本库、工作负载全塞了进来 | 砍掉所有内置 AI 与多余概念,回归纯粹的轻量级 VPS 管理工具 + 外部 MCP 接口 |
| **主机仓库信息滞后** | `osName` 等信息首次探测后成了死缓存(如 `lazy-gomami` 重装为 Ubuntu 后列表仍显示 Alpine 3.23) | SSH 连接建立时轻量静默刷新真实 OS/内核/架构信息,保持仓库数据鲜活 |
| **终端总有小 bug** | Node 仅做字节透传,服务端无终端状态机;切页丢屏、UTF-8 多字节截断乱码、resize 错位、不知命令何时结束 | 参考 Pebrel 技术栈:Rust 侧 `alacritty_terminal::Term` 持有权威网格状态 + 字节环重连恢复 + 随包 Windows ConPTY + OSC 133/7 真实命令边界与 exit code + WebSocket 纯二进制帧 |
| **文件上传下载困难(尤其 Agent 调用时)** | 传文件被拆成 `start/append/finish_file_upload` 等 9 个工具,逼大模型在上下文里手搓 base64 分块,慢且极易断 | 做独立的 Rust 流式传输引擎(接收 `本机路径 ↔ 远端路径`),内部自动流式分片、断点续传、SHA256 校验;UI 队列与 MCP 共用同一条链路 |
| **探针过重、后台刷屏** | 塞了 Agentless / Go Agent / Relay 级联三种模式与复杂告警,40,826 次 SSH 轮询占了 76% 活动,告警刷出 1.1 万条 | 重做轻量探针:复用 SSH 连接或按需/低频采样(CPU、内存、磁盘、实时网速/流量、基础负载),打开面板时实时刷新,后台静默不折腾 |
---
## 六大板块与推进路线
采用**一个板块一个板块做透**的节奏:每个板块先结合 1Shell 实际体验细化讨论,打磨好前端形态与 Rust 底层链路,验收通过后再进入下一板块。
### 板块一:VPS 仓库(底座与前端形态定型)
先把轻量级工具的**前端整体骨架与视觉形态**打磨定型,同时把底层主机管理与 SSH 连接池做扎实:
- **主机模型(Host)**:支持本机(`local`)与远端 SSH 主机;字段涵盖地址、端口、用户名、认证方式(密码 / 私钥加密存储)、跳板机(`jump_host_id`)、分组/标签、置顶、排序、备注链接,以及连接时自动刷新的 `os` 信息(系统家族、发行版、内核、架构)。
- **1Shell 一键导入**:直接读取并解密本机现有 1Shell 数据库里的 29 台 VPS,一键迁入,开发第一天起就能用真实机队测试。
- **SSH 连接底座(`russh`)**:单主机连接池与多 channel 复用(终端、执行、SFTP、探针共用连接,支持跳板机透明穿透),并在真机队(OpenSSH 8.4 ~ 10.2、Alpine、Windows OpenSSH 9.5)上验通。
- **前端打磨**:确定整个软件的导航布局、配色主题、主机列表/卡片视图、搜索与标签过滤、快速新建/编辑与连接测试交互。
### 板块二:终端(参考 Pebrel 技术栈)
**Rust 侧持有终端状态,前端只负责高速渲染。** 借鉴 Pebrel(Apache-2.0 的 `alacritty_terminal` 引擎,不抄 GPL 代码)的成熟终端工程实践:
- **双解析器与状态保活**:每个终端 pane 在 Rust 守护进程中对应一个 `alacritty_terminal::Term`(持有网格与 scrollback)+ 有界字节环(默认 2 MiB);同一份二进制字节流经 WebSocket 喂给前端 `xterm.js`(WebGL addon)。切页面、关窗口、闪断重连瞬间恢复现场。
- **PTY 链路**:本机使用 `portable-pty` 并**随包携带 Windows Terminal 的 `conpty.dll` + `OpenConsole.exe`**(告别系统老旧 ConPTY bug);远端复用板块一的 `russh` 连接打开 PTY channel。
- **Shell Integration(OSC 133 / OSC 7)**:连接时静默注入 bash / zsh / fish / PowerShell 钩子,在 Rust 解析回调中精确截获每条命令的开始、结束、真实 `exit_code` 与当前工作目录 `cwd`。
- **输入正确性**:支持 bracketed paste、按当前终端模式编码控制键与回车,杜绝粘贴多行直接误执行和乱码。
- **多 Tab 与分屏树**:支持同一主机或跨主机多 Tab、任意方向分屏与拖拽调整占比,布局持久化。
### 板块三:文件浏览器
本机文件系统与远端 `russh-sftp` 复用同一套抽象接口:
- **目录浏览与操作**:目录树/列表双视图、面包屑与路径直达、隐藏文件切换、权限/大小/修改时间展示、新建文件/文件夹、重命名、删除、修改权限。
- **在线编辑**:集成 CodeMirror 6,支持语法高亮、搜索替换、保存直写远端(带可选备份),顺手改配置文件不必下载再上传。
- **与终端联动**:支持跟随当前终端 `cwd`(由 OSC 7 提供)或在当前目录一键打开终端。
### 板块四:文件上传下载
把文件传输从普通读写里独立出来,做成一个真正的**高速流式传输引擎**:
- **核心链路**:直接基于 `本机文件路径 ↔ 远端文件路径` 流式读写,不经过内存全量缓冲、不走 base64 编码。
- **大文件与弱网保障**:内部自动按大小分块流式传输、支持断点续传、传输完成后校验大小与 SHA256、写临时文件再原子 rename(防止传一半搞坏线上文件)。
- **传输队列 UI**:支持拖拽上传、批量上传下载、实时显示传输速率、进度百分比、剩余时间,支持暂停/继续/取消。
- **为工具化铺路**:这套 Rust 传输函数做好后,板块六的 MCP 工具只需传 `{host, local_path, remote_path}` 即可直接调用,彻底消灭 1Shell 里让大模型手搓 9 个分片工具的顽疾。
### 板块五:探针
砍掉 1Shell 里繁琐的级联与告警轰炸,做一个**轻量、干净、好看的 VPS 状态看板**:
- **核心指标**:在线状态与连接延迟、系统运行时长(Uptime)、CPU 使用率与负载(Load)、内存与 Swap、磁盘占用、实时上下行网速与累计流量、关键端口/进程概览。
- **轻量采集机制**:优先复用已建立的 SSH 连接或采用极低开销的单次合并脚本采样;**按需高频、后台低频/静默**(人打开探针页或选中主机时实时刷新,平时不疯狂建连刷日志)。
- **兼容 Linux 与 Windows**:Linux(Debian/Ubuntu/Alpine)读取 `/proc` 与系统指标,Windows(`yun-computer`)走轻量性能计数采集。
### 板块六:工具化(MCP Server + 安全护栏)
前五个板块全部打磨成熟后,**零重复建设**,直接把已有的底层链路封装为 MCP Server 暴露给外部 Agent(如 Claude Code):
- **链路直接复用**:
- 查主机 / 看状态 → 复用 **板块一(VPS 仓库)** + **板块五(探针)**
- 执行命令(同步等待或转后台任务,含 Windows PowerShell 编码与 CLIXML 处理)→ 复用 **板块一/二(SSH & 本机执行链路)**
- 列目录 / 读写改删文件 → 复用 **板块三(文件浏览器内核)**
- 上传 / 下载大文件 → 复用 **板块四(文件上传下载引擎)**,一次调用完成传输
- **命令安全护栏与审批**:
- 针对 Agent 执行路径设置 TOML 规则护栏(红线 > 黑名单 > critical > 需审批 > 白名单 > 放行),每台主机可设独立安全挡位(`safe` / `standard` / `trusted`)。
- 拦截时返回清晰的 `why` + `suggestion`;需人工确认的命令进入 UI 审批队列,并在主机运行记录与审计中留痕。
- **双传输支持**:`--mcp-stdio`(供本机 Claude Code 零配置直连)+ Streamable HTTP(带范围令牌)。
---
## 技术栈
| 层 | 选型 | 备注 |
|---|---|---|
| 后端语言与运行时 | Rust + `tokio` | 单一常驻进程,轻量低内存 |
| SSH / SFTP | `russh` 0.63 + `russh-sftp` | 纯 Rust,连接池 + 多 channel 复用 + 跳板机穿透 |
| 终端引擎 | `alacritty_terminal`(Apache-2.0) | 借鉴 Pebrel:Rust 侧持有网格与 scrollback,解析回调截获 OSC 133/7 |
| 本机 PTY | `portable-pty` + 随包 Windows Terminal `conpty.dll` / `OpenConsole.exe` | 解决 Windows 原生 ConPTY 兼容性与渲染 bug |
| Web 服务与实时通信 | `axum` | REST API + 终端二进制 WebSocket + 状态/探针/传输进度 SSE |
| 本地存储 | `rusqlite`(bundled) | 单文件 SQLite + 凭据加密存储 |
| MCP 协议 | `rmcp`(≥ 1.4.0) | 板块六接入,支持 stdio 与 Streamable HTTP |
| 桌面壳 | Tauri 2 | 桌面窗口与托盘 + headless 同一二进制双模式 |
| 前端 | Vue 3 + TypeScript + Tailwind CSS + xterm.js(WebGL)+ CodeMirror 6 | 轻量、响应迅速、围绕五大板块打造精致 UI |
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>VPS Switch</title>
</head>
<body class="bg-[#f4f5f7] text-zinc-900 antialiased selection:bg-zinc-900 selection:text-white">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "vps-switch-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --port 5173",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@xterm/addon-fit": "0.10.0",
"@xterm/addon-web-links": "0.11.0",
"@xterm/addon-webgl": "0.18.0",
"@xterm/xterm": "5.5.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.2",
"vite": "^6.0.5"
}
}
+1488
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="16" fill="#18181B"/>
<rect x="2" y="2" width="60" height="60" rx="14" stroke="white" stroke-opacity="0.1" stroke-width="2"/>
<!-- 终端折线 > -->
<path d="M17 21L28 32L17 43" stroke="white" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Switch 轨道与高光橙色节点 -->
<rect x="33" y="25" width="16" height="14" rx="7" fill="#27272A" stroke="#52525B" stroke-width="2"/>
<circle cx="42" cy="32" r="4.5" fill="#F97316"/>
</svg>

After

Width:  |  Height:  |  Size: 580 B

+1987
View File
File diff suppressed because it is too large. Load diff
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
defineProps<{
size?: 'sm' | 'md' | 'lg';
}>();
</script>
<template>
<div
class=" relative inline-flex shrink-0 items-center justify-center overflow-hidden bg-zinc-900 shadow-[0_2px_8px_rgba(24,24,27,0.18)] ring-1 ring-black/5 dark:bg-zinc-100 dark:ring-white/10"
:class="{
'h-7 w-7 rounded-lg': !size || size === 'sm',
'h-9 w-9 rounded-xl': size === 'md',
'h-12 w-12 rounded-2xl': size === 'lg',
}"
>
<svg
class="h-[72%] w-[72%]"
viewBox="0 0 40 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<!-- 左侧终端命令符 > -->
<path
d="M8.5 12.5L16.5 20L8.5 27.5"
class="stroke-white dark:stroke-zinc-900"
stroke-width="3.4"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- 右侧 Switch 胶囊拨杆轨道 -->
<rect
x="19.5"
y="14.5"
width="13"
height="11"
rx="5.5"
class="stroke-white/35 fill-white/10 dark:stroke-zinc-900/35 dark:fill-zinc-900/10"
stroke-width="2"
/>
<!-- 激活态活力橙圆点(呼应右上角 + 与 Switch 概念) -->
<circle cx="27" cy="20" r="3.4" fill="#F97316" />
</svg>
</div>
</template>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,663 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import OsBadge from './OsBadge.vue';
import {
globalInspectionCache as inspectionCache,
ROLE_META,
type DiskUsageItem,
type HostInspection,
type HostRole,
type PublicHost,
} from '../types';
const netHistoryCache = new Map<string, Array<{ rx: number; tx: number }>>();
const props = defineProps<{
host: PublicHost;
checking: boolean;
}>();
const emit = defineEmits<{
close: [];
check: [host: PublicHost];
edit: [host: PublicHost];
delete: [host: PublicHost];
openTerminal: [host: PublicHost];
openFiles: [host: PublicHost];
patchMeta: [host: PublicHost, patch: Record<string, unknown>];
hostUpdated: [host: PublicHost];
}>();
const tagInput = ref('');
const snap = ref<HostInspection | null>(null);
const inspecting = ref(false);
const inspectErr = ref<string | null>(null);
const netBars = ref<Array<{ rx: number; tx: number }>>([]);
let inspectTimer: ReturnType<typeof setInterval> | null = null;
let reqSeq = 0;
const osPretty = computed(() => {
return (
props.host.osInfo?.prettyName ||
props.host.osInfo?.distroId ||
props.host.osInfo?.os ||
'正在探测系统...'
);
});
const cpuLabel = computed(() => {
return (
snap.value?.cpuModel ||
props.host.osInfo?.cpuModel ||
props.host.osInfo?.arch ||
'正在探测处理器型号...'
);
});
const memUsedPct = computed(() => {
if (!snap.value || !snap.value.memTotalBytes) return 0;
return Math.min(100, Math.round((snap.value.memUsedBytes * 100) / snap.value.memTotalBytes));
});
const memCachePct = computed(() => {
if (!snap.value || !snap.value.memTotalBytes) return 0;
const raw = Math.round((snap.value.memCachedBytes * 100) / snap.value.memTotalBytes);
return Math.min(100 - memUsedPct.value, Math.max(0, raw));
});
const swapUsedPct = computed(() => {
if (!snap.value || !snap.value.swapTotalBytes) return 0;
return Math.min(100, Math.round((snap.value.swapUsedBytes * 100) / snap.value.swapTotalBytes));
});
const diskList = computed<DiskUsageItem[]>(() => {
if (!snap.value) return [];
if (snap.value.disks && snap.value.disks.length > 0) {
return snap.value.disks;
}
if (snap.value.diskTotalBytes > 0) {
return [
{
mount: snap.value.diskMount || '/',
totalBytes: snap.value.diskTotalBytes,
usedBytes: snap.value.diskUsedBytes,
},
];
}
return [];
});
// SVG 圆环周长 (r = 22 -> 2 * pi * 22 ≈ 138.2)
const RING_CIRC = 138.2;
const cpuRingOffset = computed(() => {
const pct = Math.min(100, Math.max(0, snap.value?.cpuUsagePct || 0));
return RING_CIRC - (RING_CIRC * pct) / 100;
});
function diskPct(d: DiskUsageItem): number {
if (!d.totalBytes) return 0;
return Math.min(100, Math.round((d.usedBytes * 100) / d.totalBytes));
}
const normalizedBars = computed(() => {
const list = netBars.value;
if (!list.length) {
return Array.from({ length: 20 }, () => ({ rxH: 8, txH: 6 }));
}
const maxVal = Math.max(1024, ...list.map((b) => Math.max(b.rx, b.tx)));
const padded = [
...Array.from({ length: Math.max(0, 20 - list.length) }, () => ({ rx: 0, tx: 0 })),
...list.slice(-20),
];
return padded.map((b) => ({
rxH: Math.max(6, Math.round((b.rx / maxVal) * 100)),
txH: Math.max(6, Math.round((b.tx / maxVal) * 100)),
}));
});
function formatBytes(bytes: number): string {
if (!bytes || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const val = bytes / Math.pow(1024, i);
return `${val >= 100 || i === 0 ? val.toFixed(0) : val.toFixed(1)} ${units[i]}`;
}
function formatRate(bps: number): string {
if (!bps || bps <= 0) return '0 B/s';
return `${formatBytes(bps)}/s`;
}
function formatUptime(secs: number): string {
if (!secs || secs <= 0) return '--';
const d = Math.floor(secs / 86400);
const h = Math.floor((secs % 86400) / 3600);
const m = Math.floor((secs % 3600) / 60);
if (d > 0) return `${d}天 ${h}小时`;
if (h > 0) return `${h}小时 ${m}分`;
return `${m}分钟`;
}
async function runInspectOnce(hostId: string) {
const seq = ++reqSeq;
inspecting.value = true;
try {
const res = await fetch(`/api/hosts/${encodeURIComponent(hostId)}/inspect`);
const data = await res.json();
if (seq !== reqSeq || props.host.id !== hostId) return;
if (!data.ok || !data.inspection) {
inspectErr.value = data.error || '探索采集失败';
return;
}
inspectErr.value = null;
const ins = data.inspection as HostInspection;
snap.value = ins;
inspectionCache.set(hostId, ins);
const hist = netHistoryCache.get(hostId) || [];
hist.push({ rx: ins.netRxBps, tx: ins.netTxBps });
if (hist.length > 20) hist.shift();
netHistoryCache.set(hostId, hist);
netBars.value = [...hist];
if (data.host) {
emit('hostUpdated', data.host as PublicHost);
}
} catch (e) {
if (seq === reqSeq) {
inspectErr.value = (e as Error).message;
}
} finally {
if (seq === reqSeq) {
inspecting.value = false;
}
}
}
function startLiveInspection(hostId: string) {
stopLiveInspection();
snap.value = inspectionCache.get(hostId) || null;
netBars.value = [...(netHistoryCache.get(hostId) || [])];
inspectErr.value = null;
void runInspectOnce(hostId);
inspectTimer = setInterval(() => {
if (!inspecting.value && props.host.id === hostId) {
void runInspectOnce(hostId);
}
}, 2800);
}
function stopLiveInspection() {
if (inspectTimer) {
clearInterval(inspectTimer);
inspectTimer = null;
}
}
const roles: Array<{ key: HostRole | ''; label: string }> = [
{ key: '', label: '未标记' },
{ key: 'primary', label: ROLE_META.primary.label },
{ key: 'proxy', label: ROLE_META.proxy.label },
{ key: 'test', label: ROLE_META.test.label },
{ key: 'archive', label: ROLE_META.archive.label },
];
function addTag() {
const raw = tagInput.value.trim();
if (!raw) return;
const parts = raw
.split(/[\s,,、]+/)
.map((s) => s.trim())
.filter(Boolean);
const next = Array.from(new Set([...props.host.tags, ...parts]));
tagInput.value = '';
emit('patchMeta', props.host, { tags: next });
}
function removeTag(tag: string) {
emit(
'patchMeta',
props.host,
{ tags: props.host.tags.filter((t) => t !== tag) }
);
}
function setRole(role: HostRole | '') {
emit('patchMeta', props.host, { role: role || null });
}
watch(
() => props.host.id,
(newId) => {
startLiveInspection(newId);
}
);
onMounted(() => {
startLiveInspection(props.host.id);
});
onBeforeUnmount(() => {
stopLiveInspection();
});
</script>
<template>
<aside
class="flex h-full w-full shrink-0 flex-col overflow-hidden rounded-2xl border border-zinc-200/80 bg-white/88 shadow-[0_4px_20px_rgba(0,0,0,0.03)] backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/88"
>
<!-- 顶部栏 -->
<div class="flex items-center justify-between border-b border-zinc-100 px-4 py-3 dark:border-zinc-800">
<div class="flex min-w-0 items-center gap-2">
<span
class="h-2.5 w-2.5 shrink-0 rounded-full"
:class="{
'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.55)]': host.status === 'online',
'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.45)]': host.status === 'offline',
'bg-zinc-300 dark:bg-zinc-600': host.status === 'unknown',
}"
/>
<h3 class="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ host.name }}</h3>
<span
v-if="inspecting"
class="h-1.5 w-1.5 animate-ping rounded-full bg-zinc-400"
title="正在实时采样内核计数..."
/>
</div>
<div class="flex items-center gap-1">
<button
type="button"
class="rounded-lg px-2 py-1 text-xs font-medium text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
:disabled="inspecting"
@click="runInspectOnce(host.id)"
>
刷新
</button>
<button
type="button"
class="rounded-lg p-1 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
title="收起详情"
@click="emit('close')"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- 主内容滚动区 -->
<div class="flex-1 space-y-2.5 overflow-y-auto p-3.5">
<!-- 1. 环境底座铭牌 -->
<div class="rounded-xl border border-zinc-200/80 bg-white p-3 shadow-[0_1px_2px_rgba(0,0,0,0.02)] dark:border-zinc-800 dark:bg-zinc-900/90">
<div class="flex items-start gap-2.5">
<OsBadge :os-info="host.osInfo" :kind="host.kind" :name="host.name" />
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-1">
<span class="truncate text-xs font-semibold text-zinc-900 dark:text-zinc-100" :title="osPretty">
{{ osPretty }}
</span>
<span
v-if="snap?.tcpCc"
class="shrink-0 rounded-md px-1.5 py-0.5 font-mono text-[10px] font-semibold uppercase"
:class="
snap.tcpCc.toLowerCase().includes('bbr')
? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-500/20 dark:bg-emerald-950/60 dark:text-emerald-400'
: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300'
"
>
{{ snap.tcpCc }}
</span>
</div>
<p class="mt-0.5 truncate font-mono text-[11px] text-zinc-500 dark:text-zinc-400" :title="cpuLabel">
{{ cpuLabel }}
</p>
</div>
</div>
<div class="mt-2.5 grid grid-cols-3 divide-x divide-zinc-100 border-t border-zinc-100 pt-2 text-center dark:divide-zinc-800 dark:border-zinc-800">
<div>
<div class="text-[10px] text-zinc-400">运行时间</div>
<div class="mt-0.5 font-mono text-[11px] font-semibold text-zinc-800 dark:text-zinc-200">
{{ snap ? formatUptime(snap.uptimeSeconds) : '--' }}
</div>
</div>
<div>
<div class="text-[10px] text-zinc-400">内核 / 架构</div>
<div class="mt-0.5 truncate px-1 font-mono text-[11px] font-semibold text-zinc-800 dark:text-zinc-200" :title="host.osInfo?.kernel || ''">
{{ (host.osInfo?.kernel || host.osInfo?.arch || '--').split('-')[0] }}
</div>
</div>
<div>
<div class="text-[10px] text-zinc-400">SSH 响应</div>
<div class="mt-0.5 font-mono text-[11px] font-semibold text-zinc-800 dark:text-zinc-200">
{{ host.latencyMs != null ? `${host.latencyMs} ms` : '--' }}
</div>
</div>
</div>
</div>
<!-- 若探索报错 -->
<div
v-if="inspectErr && !snap"
class="rounded-xl border border-red-200/80 bg-red-50/70 px-3 py-2 text-[11px] leading-relaxed text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-400"
>
{{ inspectErr }}
</div>
<!-- 2. CPU 算力与负载 -->
<div class="rounded-xl border border-zinc-200/80 bg-white p-3.5 shadow-[0_1px_2px_rgba(0,0,0,0.02)] dark:border-zinc-800 dark:bg-zinc-900/90">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-[11px] font-semibold text-zinc-500 dark:text-zinc-400">CPU 算力与负载</span>
<span class="rounded bg-zinc-100 px-1.5 py-0.2 font-mono text-[10px] text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
{{ snap?.cpuCores || host.osInfo?.cpuCores || 1 }} 核
</span>
</div>
<span
v-if="snap"
class="rounded px-1.5 py-0.2 font-mono text-[10px] font-medium"
:class="
snap.cpuStealPct >= 5
? 'bg-orange-100 text-orange-700 dark:bg-orange-950/60 dark:text-orange-400'
: 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400'
"
title="宿主机邻居争抢/超售被偷走的 CPU 时间占比 (Steal)"
>
超售 Steal {{ snap.cpuStealPct.toFixed(1) }}%
</span>
</div>
<div class="mt-2.5 flex items-center gap-3.5">
<!-- 左侧 52px 圆环(自适应亮暗主题颜色) -->
<div class="relative flex h-[52px] w-[52px] shrink-0 items-center justify-center">
<svg class="h-[52px] w-[52px] -rotate-90" viewBox="0 0 52 52">
<circle
cx="26"
cy="26"
r="22"
fill="none"
class="stroke-zinc-100 dark:stroke-zinc-800"
stroke-width="4.5"
/>
<circle
cx="26"
cy="26"
r="22"
fill="none"
:class="
(snap?.cpuUsagePct || 0) > 80
? 'stroke-orange-500'
: 'stroke-zinc-900 dark:stroke-zinc-100'
"
stroke-width="4.5"
stroke-linecap="round"
:stroke-dasharray="RING_CIRC"
:stroke-dashoffset="cpuRingOffset"
class="transition-all duration-500"
/>
</svg>
<span class="font-mono text-xs font-bold text-zinc-900 dark:text-zinc-100">
{{ snap ? `${snap.cpuUsagePct.toFixed(1)}%` : '--' }}
</span>
</div>
<!-- 右侧宽敞指标矩阵 -->
<div class="grid min-w-0 flex-1 grid-cols-2 gap-x-3 gap-y-1.5 rounded-lg bg-zinc-50/80 px-3 py-2 text-[11px] dark:bg-zinc-800/50">
<div class="flex items-center justify-between">
<span class="text-zinc-400">1分负载</span>
<span class="font-mono font-semibold text-zinc-800 dark:text-zinc-200">{{ snap ? snap.load1.toFixed(2) : '--' }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-zinc-400">5分负载</span>
<span class="font-mono font-medium text-zinc-700 dark:text-zinc-300">{{ snap ? snap.load5.toFixed(2) : '--' }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-zinc-400">15分负载</span>
<span class="font-mono font-medium text-zinc-700 dark:text-zinc-300">{{ snap ? snap.load15.toFixed(2) : '--' }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-zinc-400">IOWait</span>
<span class="font-mono font-medium text-zinc-700 dark:text-zinc-300">{{ snap ? `${snap.cpuIowaitPct.toFixed(1)}%` : '--' }}</span>
</div>
</div>
</div>
</div>
<!-- 3. 内存与 Swap 解剖 -->
<div class="rounded-xl border border-zinc-200/80 bg-white p-3.5 shadow-[0_1px_2px_rgba(0,0,0,0.02)] dark:border-zinc-800 dark:bg-zinc-900/90">
<div class="flex items-center justify-between text-xs">
<span class="text-[11px] font-semibold text-zinc-500 dark:text-zinc-400">内存结构 (RAM & Swap)</span>
<span class="font-mono text-[11px] font-semibold text-zinc-900 dark:text-zinc-100">
{{ snap ? `${formatBytes(snap.memUsedBytes)} / ${formatBytes(snap.memTotalBytes)}` : '-- / --' }}
</span>
</div>
<div class="mt-2 flex h-2 w-full overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
<div
class="h-full bg-zinc-900 transition-all duration-500 dark:bg-zinc-100"
:style="{ width: `${memUsedPct}%` }"
title="应用真实占用 (MemTotal - MemAvailable)"
/>
<div
class="h-full bg-zinc-300 transition-all duration-500 dark:bg-zinc-600"
:style="{ width: `${memCachePct}%` }"
title="内核 Buffers/Cached(需要时可随时释放)"
/>
</div>
<div class="mt-2 flex items-center justify-between text-[11px] text-zinc-500 dark:text-zinc-400">
<div class="flex items-center gap-3">
<span class="inline-flex items-center gap-1">
<span class="h-2 w-2 rounded-sm bg-zinc-900 dark:bg-zinc-100" />
实占 {{ memUsedPct }}%
</span>
<span v-if="snap && snap.memCachedBytes > 0" class="inline-flex items-center gap-1">
<span class="h-2 w-2 rounded-sm bg-zinc-300 dark:bg-zinc-600" />
缓存 {{ formatBytes(snap.memCachedBytes) }}
</span>
</div>
<span class="font-mono text-[10px] text-zinc-400">
Swap:
<template v-if="snap && snap.swapTotalBytes > 0">
{{ formatBytes(snap.swapUsedBytes) }} / {{ formatBytes(snap.swapTotalBytes) }} ({{ swapUsedPct }}%)
</template>
<template v-else>未开启</template>
</span>
</div>
</div>
<!-- 4. 磁盘存储 -->
<div class="rounded-xl border border-zinc-200/80 bg-white p-3.5 shadow-[0_1px_2px_rgba(0,0,0,0.02)] dark:border-zinc-800 dark:bg-zinc-900/90">
<div class="flex items-center justify-between">
<span class="text-[11px] font-semibold text-zinc-500 dark:text-zinc-400">磁盘存储</span>
<span v-if="diskList.length === 1" class="font-mono text-[11px] font-semibold text-zinc-900 dark:text-zinc-100">
{{ formatBytes(diskList[0].usedBytes) }} / {{ formatBytes(diskList[0].totalBytes) }}
</span>
<span v-else class="font-mono text-[10px] text-zinc-400">
{{ diskList.length }} 个挂载分区
</span>
</div>
<div v-if="diskList.length" class="mt-2.5 space-y-2.5">
<div v-for="d in diskList" :key="d.mount" class="space-y-1.5">
<div class="flex items-center justify-between font-mono text-[11px]">
<span class="font-semibold text-zinc-800 dark:text-zinc-200">{{ d.mount }}</span>
<span class="text-zinc-500 dark:text-zinc-400">
{{ formatBytes(d.usedBytes) }} / {{ formatBytes(d.totalBytes) }}
<span class="ml-1 font-semibold text-zinc-900 dark:text-zinc-100">({{ diskPct(d) }}%)</span>
</span>
</div>
<div class="h-2 w-full overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
<div
class="h-full rounded-full transition-all duration-500"
:class="diskPct(d) > 85 ? 'bg-red-500' : 'bg-zinc-900 dark:bg-zinc-100'"
:style="{ width: `${diskPct(d)}%` }"
/>
</div>
</div>
</div>
<div v-else class="mt-2 text-xs text-zinc-400">正在探测磁盘容量...</div>
</div>
<!-- 5. 唯一外网主网卡实时脉冲谱 -->
<div class="rounded-xl border border-zinc-200/80 bg-white p-3.5 shadow-[0_1px_2px_rgba(0,0,0,0.02)] dark:border-zinc-800 dark:bg-zinc-900/90">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<span class="text-[11px] font-semibold text-zinc-500 dark:text-zinc-400">网络吞吐</span>
<span class="rounded bg-zinc-100 px-1.5 py-0.2 font-mono text-[10px] font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
{{ snap?.netIface || 'eth0' }}
</span>
</div>
<div class="flex items-center gap-2.5 font-mono text-[11px]">
<span class="font-semibold text-emerald-600 dark:text-emerald-400">
↓ {{ snap ? formatRate(snap.netRxBps) : '0 B/s' }}
</span>
<span class="font-semibold text-zinc-800 dark:text-zinc-200">
↑ {{ snap ? formatRate(snap.netTxBps) : '0 B/s' }}
</span>
</div>
</div>
<div class="mt-2.5 flex h-12 items-end gap-1 rounded-lg bg-zinc-50/80 px-2 py-1.5 dark:bg-zinc-800/50">
<div
v-for="(bar, idx) in normalizedBars"
:key="idx"
class="flex h-full flex-1 flex-col justify-end gap-0.5"
>
<div
class="w-full rounded-sm bg-zinc-800/75 transition-all duration-300 dark:bg-zinc-300/75"
:style="{ height: `${Math.max(8, Math.round(bar.txH * 0.45))}%` }"
/>
<div
class="w-full rounded-sm bg-emerald-500/85 transition-all duration-300"
:style="{ height: `${Math.max(8, Math.round(bar.rxH * 0.55))}%` }"
/>
</div>
</div>
<div class="mt-2 flex items-center justify-between border-t border-zinc-100 pt-2 font-mono text-[10px] text-zinc-400 dark:border-zinc-800">
<span>累计下行: <strong class="text-zinc-700 dark:text-zinc-200">{{ snap ? formatBytes(snap.netRxBytesTotal) : '--' }}</strong></span>
<span>累计上行: <strong class="text-zinc-700 dark:text-zinc-200">{{ snap ? formatBytes(snap.netTxBytesTotal) : '--' }}</strong></span>
</div>
</div>
<!-- 6. 角色、标签与快捷链接 -->
<div class="space-y-3 rounded-xl border border-zinc-200/80 bg-white p-3.5 shadow-[0_1px_2px_rgba(0,0,0,0.02)] dark:border-zinc-800 dark:bg-zinc-900/90">
<div class="flex items-center justify-between">
<span class="text-[11px] font-semibold text-zinc-500 dark:text-zinc-400">角色分类</span>
<span v-if="host.jumpHostName" class="rounded-md bg-zinc-100 px-1.5 py-0.5 text-[11px] text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
跳板: {{ host.jumpHostName }}
</span>
</div>
<div class="flex flex-wrap gap-1">
<button
v-for="r in roles"
:key="r.key"
type="button"
class="rounded-lg px-2 py-1 text-[11px] font-medium transition"
:class="
(host.role || '') === r.key
? 'bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900'
: 'bg-zinc-100 text-zinc-600 hover:bg-zinc-200/70 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700'
"
@click="setRole(r.key)"
>
{{ r.label }}
</button>
</div>
<div class="pt-1">
<div class="text-[11px] font-semibold text-zinc-500 dark:text-zinc-400">标签</div>
<div class="mt-1.5 flex flex-wrap gap-1.5">
<span
v-for="tag in host.tags"
:key="tag"
class="inline-flex items-center gap-1 rounded-md border border-zinc-200 bg-zinc-50 px-2 py-0.5 text-[11px] text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
>
#{{ tag }}
<button
type="button"
class="text-zinc-400 hover:text-zinc-800 dark:hover:text-zinc-100"
@click="removeTag(tag)"
>
×
</button>
</span>
</div>
<form class="mt-2 flex gap-1.5" @submit.prevent="addTag">
<input
v-model="tagInput"
type="text"
placeholder="添加标签(回车保存)"
class="min-w-0 flex-1 rounded-lg border border-zinc-200 bg-zinc-50/70 px-2.5 py-1 text-xs text-zinc-800 outline-none focus:border-zinc-400 focus:bg-white dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
/>
<button
type="submit"
class="rounded-lg border border-zinc-200 bg-white px-2.5 py-1 text-xs font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"
>
添加
</button>
</form>
</div>
<div v-if="host.links.length" class="pt-1">
<div class="text-[11px] font-semibold text-zinc-500 dark:text-zinc-400">快捷链接</div>
<div class="mt-1.5 space-y-1">
<a
v-for="link in host.links"
:key="link.id || link.url"
:href="link.url"
target="_blank"
rel="noreferrer"
class="flex items-center justify-between rounded-lg border border-zinc-200/70 bg-zinc-50/60 px-2.5 py-1.5 text-xs text-zinc-700 transition hover:border-zinc-300 hover:bg-white dark:border-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<span class="truncate font-medium">{{ link.name }}</span>
<span class="ml-2 truncate font-mono text-[10px] text-zinc-400">{{ link.url }}</span>
</a>
</div>
</div>
</div>
</div>
<!-- 底部操作栏 -->
<div class="flex items-center justify-between border-t border-zinc-100 bg-zinc-50/60 px-3.5 py-2.5 dark:border-zinc-800 dark:bg-zinc-900/60">
<div class="flex items-center gap-1.5">
<button
type="button"
class="rounded-lg bg-zinc-900 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white"
@click="emit('openTerminal', host)"
>
打开终端
</button>
<button
type="button"
class="rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-xs font-medium text-zinc-700 transition hover:bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
@click="emit('openFiles', host)"
>
文件浏览
</button>
</div>
<div class="flex items-center gap-1">
<button
type="button"
class="rounded-lg px-2.5 py-1.5 text-xs font-medium text-zinc-600 hover:bg-zinc-200/60 dark:text-zinc-400 dark:hover:bg-zinc-800"
@click="emit('edit', host)"
>
编辑
</button>
<button
v-if="host.kind !== 'local'"
type="button"
class="rounded-lg px-2 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
@click="emit('delete', host)"
>
删除
</button>
</div>
</div>
</aside>
</template>
+389
View File
@@ -0,0 +1,389 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { ROLE_META, type HostLink, type HostRole, type HostUpsertPayload, type PublicHost } from '../types';
const props = defineProps<{
open: boolean;
editing: PublicHost | null;
sshHosts: PublicHost[];
}>();
const emit = defineEmits<{
close: [];
submit: [payload: HostUpsertPayload, editingId: string | null];
}>();
const name = ref('');
const addr = ref('');
const port = ref(22);
const username = ref('root');
const authType = ref<'password' | 'privateKey'>('password');
const password = ref('');
const privateKey = ref('');
const passphrase = ref('');
const jumpHostId = ref('');
const role = ref<HostRole | ''>('');
const tagsText = ref('');
const pinned = ref(false);
const archived = ref(false);
const links = ref<HostLink[]>([]);
const errorMsg = ref('');
const isEditing = computed(() => Boolean(props.editing?.id));
const isLocal = computed(() => props.editing?.kind === 'local');
const jumpCandidates = computed(() =>
props.sshHosts.filter((h) => h.kind === 'ssh' && h.id !== props.editing?.id)
);
const roleOptions: Array<{ value: HostRole | ''; label: string }> = [
{ value: '', label: '未标记' },
{ value: 'primary', label: ROLE_META.primary.label },
{ value: 'proxy', label: ROLE_META.proxy.label },
{ value: 'test', label: ROLE_META.test.label },
{ value: 'archive', label: ROLE_META.archive.label },
];
watch(
() => [props.open, props.editing] as const,
([open, host]) => {
if (!open) return;
errorMsg.value = '';
if (host) {
name.value = host.name;
addr.value = host.addr;
port.value = host.port || 22;
username.value = host.username || 'root';
authType.value = host.authType === 'privateKey' ? 'privateKey' : 'password';
jumpHostId.value = host.jumpHostId || '';
role.value = (host.role as HostRole) || '';
tagsText.value = host.tags.join(', ');
pinned.value = host.pinned;
archived.value = host.archived;
links.value = host.links.length
? host.links.map((l) => ({ ...l }))
: [{ id: '', name: '', url: '', description: '' }];
} else {
name.value = '';
addr.value = '';
port.value = 22;
username.value = 'root';
authType.value = 'password';
jumpHostId.value = '';
role.value = '';
tagsText.value = '';
pinned.value = false;
archived.value = false;
links.value = [{ id: '', name: '', url: '', description: '' }];
}
password.value = '';
privateKey.value = '';
passphrase.value = '';
},
{ immediate: true }
);
function addLinkRow() {
links.value.push({ id: '', name: '', url: '', description: '' });
}
function removeLinkRow(idx: number) {
links.value.splice(idx, 1);
if (!links.value.length) {
links.value.push({ id: '', name: '', url: '', description: '' });
}
}
function handleSubmit() {
errorMsg.value = '';
if (!name.value.trim()) {
errorMsg.value = '请填写主机名称';
return;
}
if (!isLocal.value && !addr.value.trim()) {
errorMsg.value = '请填写主机 IP 或域名';
return;
}
const parsedTags = tagsText.value
.split(/[\s,,、]+/)
.map((s) => s.trim())
.filter(Boolean);
const validLinks = links.value
.filter((l) => l.name.trim() && l.url.trim())
.map((l) => ({
id: l.id || '',
name: l.name.trim(),
url: l.url.trim(),
description: l.description.trim(),
}));
const payload: HostUpsertPayload = {
name: name.value.trim(),
addr: isLocal.value ? '127.0.0.1' : addr.value.trim(),
port: Number(port.value) || 22,
username: username.value.trim() || 'root',
authType: authType.value,
jumpHostId: jumpHostId.value || null,
role: role.value || null,
tags: parsedTags,
links: validLinks,
pinned: pinned.value,
archived: archived.value,
};
if (password.value) payload.password = password.value;
if (privateKey.value.trim()) payload.privateKey = privateKey.value;
if (passphrase.value) payload.passphrase = passphrase.value;
emit('submit', payload, props.editing?.id || null);
}
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="fixed inset-0 z-50 flex items-center justify-center bg-zinc-900/20 p-4 backdrop-blur-sm"
@click.self="emit('close')"
>
<div
class="flex max-h-[88vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-zinc-200/90 bg-white/95 shadow-2xl backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/95 dark:text-zinc-100 [&_input]:dark:bg-zinc-800 [&_input]:dark:border-zinc-700 [&_input]:dark:text-zinc-100 [&_select]:dark:bg-zinc-800 [&_select]:dark:border-zinc-700 [&_select]:dark:text-zinc-100 [&_textarea]:dark:bg-zinc-800 [&_textarea]:dark:border-zinc-700 [&_textarea]:dark:text-zinc-100 [&_label]:dark:text-zinc-300"
>
<div class="flex items-center justify-between border-b border-zinc-100 px-5 py-3.5 dark:border-zinc-800">
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{{ isEditing ? `编辑主机 · ${editing?.name}` : '新增 VPS 主机' }}
</h3>
<button
type="button"
class="rounded-lg p-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700"
@click="emit('close')"
>
✕
</button>
</div>
<form class="flex-1 space-y-3.5 overflow-y-auto px-5 py-4" @submit.prevent="handleSubmit">
<div
v-if="errorMsg"
class="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700"
>
{{ errorMsg }}
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-zinc-600">主机名称</label>
<input
v-model="name"
type="text"
placeholder="如 lazy-jp / 主力机"
class="mt-1 w-full rounded-xl border border-zinc-200 bg-zinc-50/60 px-3 py-2 text-xs text-zinc-900 outline-none focus:border-zinc-400 focus:bg-white"
/>
</div>
<div>
<label class="block text-xs font-medium text-zinc-600">角色分类</label>
<select
v-model="role"
class="mt-1 w-full rounded-xl border border-zinc-200 bg-zinc-50/60 px-3 py-2 text-xs text-zinc-900 outline-none focus:border-zinc-400 focus:bg-white"
>
<option v-for="r in roleOptions" :key="r.value" :value="r.value">
{{ r.label }}
</option>
</select>
</div>
</div>
<template v-if="!isLocal">
<div class="grid grid-cols-[1fr_96px_110px] gap-2.5">
<div>
<label class="block text-xs font-medium text-zinc-600">IP / 域名</label>
<input
v-model="addr"
type="text"
placeholder="1.2.3.4 或 example.com"
class="mt-1 w-full rounded-xl border border-zinc-200 bg-zinc-50/60 px-3 py-2 font-mono text-xs text-zinc-900 outline-none focus:border-zinc-400 focus:bg-white"
/>
</div>
<div>
<label class="block text-xs font-medium text-zinc-600">端口</label>
<input
v-model.number="port"
type="number"
min="1"
max="65535"
class="mt-1 w-full rounded-xl border border-zinc-200 bg-zinc-50/60 px-3 py-2 font-mono text-xs text-zinc-900 outline-none focus:border-zinc-400 focus:bg-white"
/>
</div>
<div>
<label class="block text-xs font-medium text-zinc-600">用户名</label>
<input
v-model="username"
type="text"
placeholder="root"
class="mt-1 w-full rounded-xl border border-zinc-200 bg-zinc-50/60 px-3 py-2 font-mono text-xs text-zinc-900 outline-none focus:border-zinc-400 focus:bg-white"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-zinc-600">认证方式</label>
<div class="mt-1 grid grid-cols-2 gap-1 rounded-xl bg-zinc-100 p-1">
<button
type="button"
class="rounded-lg py-1.5 text-xs font-medium transition"
:class="authType === 'password' ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-500'"
@click="authType = 'password'"
>
密码
</button>
<button
type="button"
class="rounded-lg py-1.5 text-xs font-medium transition"
:class="authType === 'privateKey' ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-500'"
@click="authType = 'privateKey'"
>
SSH 私钥
</button>
</div>
</div>
<div>
<label class="block text-xs font-medium text-zinc-600">跳板机(可选)</label>
<select
v-model="jumpHostId"
class="mt-1 w-full rounded-xl border border-zinc-200 bg-zinc-50/60 px-3 py-2 text-xs text-zinc-900 outline-none focus:border-zinc-400 focus:bg-white"
>
<option value="">直连(不使用跳板)</option>
<option v-for="j in jumpCandidates" :key="j.id" :value="j.id">
{{ j.name }} ({{ j.addr }}:{{ j.port }})
</option>
</select>
</div>
</div>
<div v-if="authType === 'password'">
<label class="block text-xs font-medium text-zinc-600">
SSH 密码
<span v-if="isEditing && editing?.hasPassword" class="text-zinc-400">
(已保存加密密码,留空则保持不变)
</span>
</label>
<input
v-model="password"
type="password"
placeholder="输入 SSH 密码"
class="mt-1 w-full rounded-xl border border-zinc-200 bg-zinc-50/60 px-3 py-2 font-mono text-xs text-zinc-900 outline-none focus:border-zinc-400 focus:bg-white"
/>
</div>
<div v-else class="space-y-2.5">
<div>
<label class="block text-xs font-medium text-zinc-600">
SSH 私钥 (PEM / OpenSSH)
<span v-if="isEditing && editing?.hasPrivateKey" class="text-zinc-400">
(已保存加密私钥,留空则保持不变)
</span>
</label>
<textarea
v-model="privateKey"
rows="3"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
class="mt-1 w-full rounded-xl border border-zinc-200 bg-zinc-50/60 px-3 py-2 font-mono text-xs text-zinc-900 outline-none focus:border-zinc-400 focus:bg-white"
/>
</div>
<div>
<label class="block text-xs font-medium text-zinc-600">私钥口令 Passphrase(可选)</label>
<input
v-model="passphrase"
type="password"
placeholder="无口令可留空"
class="mt-1 w-full rounded-xl border border-zinc-200 bg-zinc-50/60 px-3 py-2 font-mono text-xs text-zinc-900 outline-none focus:border-zinc-400 focus:bg-white"
/>
</div>
</div>
</template>
<div>
<label class="block text-xs font-medium text-zinc-600">标签(逗号或空格分隔)</label>
<input
v-model="tagsText"
type="text"
placeholder="如 hk, bgp, docker"
class="mt-1 w-full rounded-xl border border-zinc-200 bg-zinc-50/60 px-3 py-2 text-xs text-zinc-900 outline-none focus:border-zinc-400 focus:bg-white"
/>
</div>
<div>
<div class="flex items-center justify-between">
<label class="text-xs font-medium text-zinc-600">快捷链接(商家后台 / 面板)</label>
<button
type="button"
class="text-xs font-medium text-zinc-600 hover:text-zinc-900"
@click="addLinkRow"
>
+ 添加一行
</button>
</div>
<div class="mt-1.5 space-y-1.5">
<div
v-for="(link, idx) in links"
:key="idx"
class="grid grid-cols-[110px_1fr_28px] gap-1.5"
>
<input
v-model="link.name"
type="text"
placeholder="名称"
class="rounded-lg border border-zinc-200 bg-zinc-50/60 px-2.5 py-1.5 text-xs outline-none focus:border-zinc-400 focus:bg-white"
/>
<input
v-model="link.url"
type="text"
placeholder="https://..."
class="rounded-lg border border-zinc-200 bg-zinc-50/60 px-2.5 py-1.5 font-mono text-xs outline-none focus:border-zinc-400 focus:bg-white"
/>
<button
type="button"
class="rounded-lg text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700"
@click="removeLinkRow(idx)"
>
×
</button>
</div>
</div>
</div>
<div v-if="!isLocal" class="flex items-center gap-5 pt-1">
<label class="inline-flex items-center gap-2 text-xs text-zinc-700">
<input v-model="pinned" type="checkbox" class="rounded border-zinc-300 text-zinc-900" />
置顶显示
</label>
<label class="inline-flex items-center gap-2 text-xs text-zinc-700">
<input v-model="archived" type="checkbox" class="rounded border-zinc-300 text-zinc-900" />
归档隐藏
</label>
</div>
</form>
<div class="flex items-center justify-end gap-2 border-t border-zinc-100 bg-zinc-50/60 px-5 py-3">
<button
type="button"
class="rounded-xl border border-zinc-200 bg-white px-4 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
@click="emit('close')"
>
取消
</button>
<button
type="button"
class="rounded-xl bg-zinc-900 px-4 py-1.5 text-xs font-medium text-white hover:bg-zinc-800"
@click="handleSubmit"
>
保存
</button>
</div>
</div>
</div>
</Teleport>
</template>
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { OsInfo } from '../types';
const props = defineProps<{
osInfo?: OsInfo | null;
kind?: 'local' | 'ssh';
name?: string;
size?: 'sm' | 'md';
}>();
const distro = computed(() => {
const id = (props.osInfo?.distroId || '').toLowerCase();
const pretty = (props.osInfo?.prettyName || '').toLowerCase();
const os = (props.osInfo?.os || '').toLowerCase();
if (os.includes('win') || id.includes('win') || pretty.includes('windows')) return 'windows';
if (id.includes('ubuntu') || pretty.includes('ubuntu')) return 'ubuntu';
if (id.includes('debian') || pretty.includes('debian')) return 'debian';
if (id.includes('alpine') || pretty.includes('alpine')) return 'alpine';
return 'linux';
});
const label = computed(() => {
switch (distro.value) {
case 'ubuntu':
return 'UB';
case 'debian':
return 'DB';
case 'alpine':
return 'AL';
case 'windows':
return 'WN';
default:
return (props.name || 'VP').slice(0, 2).toUpperCase();
}
});
</script>
<template>
<div
class="inline-flex shrink-0 items-center justify-center rounded-full border border-zinc-200/80 bg-zinc-100/90 font-semibold tracking-tight text-zinc-700 select-none dark:border-zinc-700/80 dark:bg-zinc-800/90 dark:text-zinc-200"
:class="size === 'sm' ? 'h-6 w-6 text-[10px]' : 'h-10 w-10 text-xs'"
>
<!-- Ubuntu -->
<svg v-if="distro === 'ubuntu'" class="h-4 w-4 text-orange-600" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="4.2" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="19" cy="12" r="1.8" />
<circle cx="8.5" cy="5.8" r="1.8" />
<circle cx="8.5" cy="18.2" r="1.8" />
</svg>
<!-- Debian -->
<svg v-else-if="distro === 'debian'" class="h-4 w-4 text-rose-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M12 15a3 3 0 1 0-3-3c0 3 2.5 5.5 6 5.5 4 0 6-3 6-6.5C21 6.5 17 3 12 3S3 6.8 3 12c0 5 4 9 9 9" />
</svg>
<!-- Alpine -->
<svg v-else-if="distro === 'alpine'" class="h-4 w-4 text-zinc-700 dark:text-zinc-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round">
<path d="M3 18L9.5 7l4.5 7.5L16.5 11 21 18H3z" />
</svg>
<!-- Windows -->
<svg v-else-if="distro === 'windows'" class="h-3.5 w-3.5 text-zinc-700 dark:text-zinc-300" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 3h8.5v8.5H3V3zm9.5 0H21v8.5h-8.5V3zM3 12.5h8.5V21H3v-8.5zm9.5 0H21V21h-8.5v-8.5z" />
</svg>
<span v-else>{{ label }}</span>
</div>
</template>
@@ -0,0 +1,302 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Terminal, type ITheme } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebglAddon } from '@xterm/addon-webgl';
import { WebLinksAddon } from '@xterm/addon-web-links';
import '@xterm/xterm/css/xterm.css';
import type { PaneMeta } from '../types';
const props = defineProps<{
paneId: string;
dark: boolean;
focused: boolean;
showClose?: boolean;
}>();
const emit = defineEmits<{
focus: [];
closePane: [paneId: string];
metaUpdate: [meta: PaneMeta];
}>();
const containerRef = ref<HTMLDivElement | null>(null);
const connected = ref(false);
const exited = ref(false);
let term: Terminal | null = null;
let fitAddon: FitAddon | null = null;
let ws: WebSocket | null = null;
let resizeObserver: ResizeObserver | null = null;
let fitRaf: number | null = null;
let replayingDepth = 0;
const textEncoder = new TextEncoder();
// 拦截 xterm.js 自动发出的设备属性应答(如 DA1 \x1b[?1;2c、DA2 \x1b[>0;276;0c),防止在 Shell 提示符上打出 [?1;2c
const AUTO_TERMINAL_RESPONSE_RE = /^\x1b\[[?>=][0-9;]*c$/;
// 清爽高对比白底主题(严格参考图 4 Reflex 白底终端)
const LIGHT_THEME: ITheme = {
background: '#ffffff',
foreground: '#18181b',
cursor: '#18181b',
cursorAccent: '#ffffff',
selectionBackground: 'rgba(24, 24, 27, 0.14)',
selectionForeground: '#18181b',
black: '#18181b',
red: '#dc2626',
green: '#16a34a',
yellow: '#ca8a04',
blue: '#2563eb',
magenta: '#9333ea',
cyan: '#0891b2',
white: '#e4e4e7',
brightBlack: '#71717a',
brightRed: '#ef4444',
brightGreen: '#22c55e',
brightYellow: '#eab308',
brightBlue: '#3b82f6',
brightMagenta: '#a855f7',
brightCyan: '#06b6d4',
brightWhite: '#fafafa',
};
// 经典深色终端主题(供一键切换)
const DARK_THEME: ITheme = {
background: '#0c0d10',
foreground: '#e4e4e7',
cursor: '#f4f4f5',
cursorAccent: '#0c0d10',
selectionBackground: 'rgba(255, 255, 255, 0.18)',
black: '#27272a',
red: '#f87171',
green: '#4ade80',
yellow: '#facc15',
blue: '#60a5fa',
magenta: '#c084fc',
cyan: '#22d3ee',
white: '#e4e4e7',
brightBlack: '#71717a',
brightRed: '#fca5a5',
brightGreen: '#86efac',
brightYellow: '#fde047',
brightBlue: '#93c5fd',
brightMagenta: '#d8b4fe',
brightCyan: '#67e8f9',
brightWhite: '#ffffff',
};
function sendBinaryInput(data: string) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const bytes = textEncoder.encode(data);
const frame = new Uint8Array(1 + bytes.length);
frame[0] = 0x01;
frame.set(bytes, 1);
ws.send(frame);
}
function sendControl(payload: Record<string, unknown>) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify(payload));
}
function scheduleFit() {
if (fitRaf != null) cancelAnimationFrame(fitRaf);
fitRaf = requestAnimationFrame(() => {
fitRaf = null;
if (!term || !fitAddon || !containerRef.value) return;
if (containerRef.value.clientWidth < 40 || containerRef.value.clientHeight < 40) return;
try {
fitAddon.fit();
if (term.cols >= 2 && term.rows >= 1) {
sendControl({
type: 'resize',
cols: term.cols,
rows: term.rows,
});
}
} catch {
// ignore transient layout errors
}
});
}
function connectWebSocket() {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${window.location.host}/api/ws/terminal/${encodeURIComponent(props.paneId)}`;
ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
connected.value = true;
scheduleFit();
};
ws.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) {
const view = new Uint8Array(ev.data);
if (view.length > 1 && view[0] === 0x00) {
// 实时输出帧
term?.write(view.subarray(1));
} else if (view.length > 1 && view[0] === 0x02) {
// 历史字节环回放帧:写入期间静默屏蔽所有 xterm 自动应答
if (term) {
replayingDepth += 1;
term.write(view.subarray(1), () => {
replayingDepth = Math.max(0, replayingDepth - 1);
});
}
}
return;
}
if (typeof ev.data === 'string') {
try {
const msg = JSON.parse(ev.data);
if (msg.type === 'meta' && msg.pane) {
exited.value = Boolean(msg.pane.exited);
emit('metaUpdate', msg.pane as PaneMeta);
}
} catch {
// ignore
}
}
};
ws.onclose = () => {
connected.value = false;
};
}
watch(
() => props.dark,
(isDark) => {
if (term) {
term.options.theme = isDark ? DARK_THEME : LIGHT_THEME;
}
}
);
onMounted(() => {
if (!containerRef.value) return;
term = new Terminal({
cursorBlink: true,
cursorStyle: 'block',
fontFamily: '"JetBrains Mono", "Cascadia Mono", Consolas, "PingFang SC", "Microsoft YaHei", monospace',
fontSize: 13.5,
lineHeight: 1.32,
scrollback: 5000,
allowProposedApi: true,
theme: props.dark ? DARK_THEME : LIGHT_THEME,
});
fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon());
term.open(containerRef.value);
try {
const webgl = new WebglAddon();
webgl.onContextLoss(() => {
webgl.dispose();
});
term.loadAddon(webgl);
} catch {
// WebGL 不可用时自动退回 Canvas 渲染
}
// 智能快捷键:有选区时 Ctrl+C 复制;Ctrl+V 走安全 bracketed paste
term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
if (e.type !== 'keydown') return true;
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c' && !e.shiftKey && !e.altKey) {
const sel = term?.getSelection();
if (sel) {
void navigator.clipboard.writeText(sel);
term?.clearSelection();
return false;
}
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v' && !e.shiftKey && !e.altKey) {
e.preventDefault();
void navigator.clipboard.readText().then((txt) => {
if (txt) sendControl({ type: 'paste', text: txt });
});
return false;
}
return true;
});
term.onData((data) => {
if (replayingDepth > 0) return;
if (AUTO_TERMINAL_RESPONSE_RE.test(data)) return;
sendBinaryInput(data);
});
resizeObserver = new ResizeObserver(() => {
scheduleFit();
});
resizeObserver.observe(containerRef.value);
connectWebSocket();
term.focus();
});
onBeforeUnmount(() => {
if (fitRaf != null) cancelAnimationFrame(fitRaf);
resizeObserver?.disconnect();
if (ws) {
ws.onclose = null;
ws.close();
ws = null;
}
term?.dispose();
term = null;
});
</script>
<template>
<div
class="group/pane relative flex h-full w-full min-h-0 min-w-0 flex-col overflow-hidden transition-colors"
:class="[
dark ? 'bg-[#0c0d10]' : 'bg-white',
focused ? 'ring-1 ring-inset ring-zinc-900/10' : '',
]"
@mousedown="emit('focus')"
>
<!-- 分屏时的右上角悬浮关闭小按钮 -->
<button
v-if="showClose"
type="button"
class="absolute right-2.5 top-2 z-20 hidden rounded-lg border px-1.5 py-0.5 text-[11px] opacity-80 transition group-hover/pane:inline-flex"
:class="
dark
? 'border-zinc-700 bg-zinc-800 text-zinc-300 hover:bg-zinc-700'
: 'border-zinc-200 bg-white/90 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900'
"
title="关闭此分屏"
@click.stop="emit('closePane', paneId)"
>
关闭分屏 ✕
</button>
<!-- xterm 挂载容器 -->
<div ref="containerRef" class="h-full w-full flex-1 overflow-hidden px-3 py-2" />
<!-- 会话已退出提示条 -->
<div
v-if="exited"
class="flex items-center justify-between border-t border-zinc-200/80 bg-zinc-50 px-3 py-1.5 text-xs text-zinc-500"
>
<span>终端进程已退出</span>
<button
v-if="showClose"
type="button"
class="rounded-md bg-zinc-900 px-2 py-0.5 text-white"
@click="emit('closePane', paneId)"
>
移除面板
</button>
</div>
</div>
</template>
+201
View File
@@ -0,0 +1,201 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import TerminalPaneView from './TerminalPaneView.vue';
import type { PaneMeta, PublicHost } from '../types';
const props = defineProps<{
host: PublicHost;
panes: PaneMeta[];
splitDirection: 'row' | 'col';
dark: boolean;
rightWing: 'detail' | 'files' | null;
creating: boolean;
}>();
const emit = defineEmits<{
split: [direction: 'row' | 'col'];
closePane: [paneId: string];
toggleDark: [];
toggleRightWing: [mode: 'files' | 'detail'];
collapseStage: [];
metaUpdate: [meta: PaneMeta];
}>();
const focusedPaneId = ref<string | null>(null);
const activePane = computed(() => {
return (
props.panes.find((p) => p.id === focusedPaneId.value) ||
props.panes[props.panes.length - 1] ||
null
);
});
</script>
<template>
<section
class="flex h-full w-full min-w-[420px] flex-col overflow-hidden rounded-2xl border border-zinc-200/80 bg-white/95 shadow-[0_4px_24px_rgba(0,0,0,0.04)] backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/95"
>
<!-- 舞台顶栏:主机状态 + OSC 7 实时目录 + OSC 133 命令状态 + 分屏/黑白底/右侧等宽栏开关 -->
<div class="flex shrink-0 items-center justify-between gap-2 border-b border-zinc-100 px-4 py-2.5 dark:border-zinc-800">
<!-- 左侧信息 -->
<div class="flex min-w-0 items-center gap-2">
<span
class="h-2.5 w-2.5 shrink-0 rounded-full"
:class="host.status === 'online' ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' : 'bg-zinc-300 dark:bg-zinc-600'"
/>
<span class="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{{ host.name }}
</span>
<span class="hidden sm:inline font-mono text-xs text-zinc-400">
{{ host.kind === 'local' ? 'PowerShell (ConPTY)' : `${host.username}@${host.addr}:${host.port}` }}
</span>
<!-- OSC 7 实时工作目录胶囊 -->
<span
v-if="activePane?.cwd"
class="inline-flex max-w-[200px] items-center gap-1 truncate rounded-lg border border-zinc-200/80 bg-zinc-50 px-2 py-0.5 font-mono text-[11px] text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
:title="`当前目录: ${activePane.cwd}`"
>
<svg class="h-3 w-3 shrink-0 text-zinc-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" />
</svg>
<span class="truncate">{{ activePane.cwd }}</span>
</span>
<!-- OSC 133 实时命令运行态与真实 Exit Code -->
<span
v-if="activePane?.running"
class="inline-flex items-center gap-1 rounded-lg bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:bg-amber-950/60 dark:text-amber-400"
>
<span class="h-1.5 w-1.5 animate-ping rounded-full bg-amber-500" />
运行中
</span>
<span
v-else-if="activePane?.lastExitCode != null"
class="inline-flex items-center rounded-lg px-1.5 py-0.5 font-mono text-[10px] font-medium"
:class="
activePane.lastExitCode === 0
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-400'
: 'bg-red-50 text-red-600 dark:bg-red-950/60 dark:text-red-400'
"
:title="`上一条命令退出码: ${activePane.lastExitCode}`"
>
{{ activePane.lastExitCode === 0 ? '✓ 0' : `✗ ${activePane.lastExitCode}` }}
</span>
</div>
<!-- 右侧工具条:分屏 + 主题 + 右侧等宽栏(文件管理 / 详情图表) + 收起 -->
<div class="flex shrink-0 items-center gap-1.5">
<!-- 左右分屏 / 上下分屏 -->
<div class="flex items-center rounded-xl border border-zinc-200/80 bg-zinc-50/80 p-0.5 dark:border-zinc-700 dark:bg-zinc-800">
<button
type="button"
class="rounded-lg px-2 py-1 text-[11px] font-medium text-zinc-600 transition hover:bg-white hover:text-zinc-900 hover:shadow-sm disabled:opacity-40 dark:text-zinc-300 dark:hover:bg-zinc-700 dark:hover:text-white"
:disabled="creating"
title="向右分屏(复用当前 SSH 连接)"
@click="emit('split', 'row')"
>
左右分屏
</button>
<button
type="button"
class="rounded-lg px-2 py-1 text-[11px] font-medium text-zinc-600 transition hover:bg-white hover:text-zinc-900 hover:shadow-sm disabled:opacity-40 dark:text-zinc-300 dark:hover:bg-zinc-700 dark:hover:text-white"
:disabled="creating"
title="向下分屏(复用当前 SSH 连接)"
@click="emit('split', 'col')"
>
上下分屏
</button>
</div>
<!-- 白底 / 黑底一键切换 -->
<button
type="button"
class="rounded-xl border border-zinc-200/80 bg-white px-2.5 py-1 text-xs font-medium text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
:title="dark ? '切换为清爽白底终端' : '切换为深色黑底终端'"
@click="emit('toggleDark')"
>
{{ dark ? '白底' : '黑底' }}
</button>
<!-- 在最右栏展开与左栏等宽(336px)的「文件管理」 -->
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1 text-xs font-medium transition"
:class="
rightWing === 'files'
? 'border-zinc-900 bg-zinc-900 text-white shadow-sm dark:border-zinc-100 dark:bg-zinc-100 dark:text-zinc-900'
: 'border-zinc-200/80 bg-white text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700'
"
title="在右侧等宽栏展开文件管理"
@click="emit('toggleRightWing', 'files')"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" />
</svg>
<span>文件管理</span>
</button>
<!-- 在最右栏展开与左栏等宽(336px)的「详情图表」 -->
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1 text-xs font-medium transition"
:class="
rightWing === 'detail'
? 'border-zinc-900 bg-zinc-900 text-white shadow-sm dark:border-zinc-100 dark:bg-zinc-100 dark:text-zinc-900'
: 'border-zinc-200/80 bg-white text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700'
"
title="在右侧等宽栏展开详情图表"
@click="emit('toggleRightWing', 'detail')"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 20V10M12 20V4M6 20v-6" />
</svg>
<span>详情图表</span>
</button>
<button
type="button"
class="ml-0.5 rounded-lg p-1 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-800 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
title="收起舞台回到仓库(终端后台保活,可从左上角标签秒切回)"
@click="emit('collapseStage')"
>
✕
</button>
</div>
</div>
<!-- 终端分屏画布区 -->
<div
v-if="panes.length"
class="flex min-h-0 flex-1 divide-zinc-200/80 overflow-hidden"
:class="splitDirection === 'col' ? 'flex-col divide-y' : 'flex-row divide-x'"
>
<div
v-for="pane in panes"
:key="pane.id"
class="min-h-0 min-w-0 flex-1 overflow-hidden"
>
<TerminalPaneView
:pane-id="pane.id"
:dark="dark"
:focused="focusedPaneId === pane.id || panes.length === 1"
:show-close="panes.length > 1"
@focus="focusedPaneId = pane.id"
@close-pane="emit('closePane', $event)"
@meta-update="emit('metaUpdate', $event)"
/>
</div>
</div>
<!-- 正在握手建立连接时的加载态 -->
<div
v-else
class="flex flex-1 flex-col items-center justify-center gap-2 text-xs text-zinc-400"
>
<div class="h-5 w-5 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-800" />
<span>正在连接 {{ host.name }} 终端...</span>
</div>
</section>
</template>
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
createApp(App).mount('#app');
+43
View File
@@ -0,0 +1,43 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html,
body,
#app {
height: 100%;
margin: 0;
overflow: hidden;
background: radial-gradient(120% 120% at 50% 0%, #f9fafb 0%, #f1f3f6 100%);
transition: background 0.2s ease, color 0.2s ease;
}
html.dark,
html.dark body,
html.dark #app {
background: radial-gradient(120% 120% at 50% 0%, #111215 0%, #090a0c 100%);
color: #f4f4f5;
}
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(24, 24, 27, 0.14);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(24, 24, 27, 0.26);
}
html.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.14);
}
html.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.26);
}
+161
View File
@@ -0,0 +1,161 @@
export interface HostLink {
id: string;
name: string;
url: string;
description: string;
}
export interface OsInfo {
os?: string | null;
distroId?: string | null;
versionId?: string | null;
prettyName?: string | null;
arch?: string | null;
kernel?: string | null;
cpuModel?: string | null;
cpuCores?: number | null;
detectedAt?: string | null;
}
export type HostRole = 'primary' | 'proxy' | 'test' | 'archive';
export interface PublicHost {
id: string;
name: string;
kind: 'local' | 'ssh';
addr: string;
port: number;
username: string;
authType: 'local' | 'password' | 'privateKey';
hasPassword: boolean;
hasPrivateKey: boolean;
hasPassphrase: boolean;
jumpHostId?: string | null;
jumpHostName?: string | null;
role?: HostRole | null;
tags: string[];
links: HostLink[];
osInfo?: OsInfo | null;
pinned: boolean;
favorited?: boolean;
archived: boolean;
sortOrder: number;
status: 'online' | 'offline' | 'unknown';
latencyMs?: number | null;
lastConnectedAt?: string | null;
lastError?: string | null;
createdAt: string;
updatedAt: string;
}
export interface HostUpsertPayload {
name: string;
addr: string;
port: number;
username: string;
authType: 'password' | 'privateKey';
password?: string;
privateKey?: string;
passphrase?: string;
jumpHostId?: string | null;
role?: string | null;
tags: string[];
links: HostLink[];
pinned: boolean;
archived: boolean;
}
export const ROLE_META: Record<HostRole, { label: string }> = {
primary: { label: '主力' },
proxy: { label: '代理' },
test: { label: '测试' },
archive: { label: '归档' },
};
export interface PaneMeta {
id: string;
hostId: string;
cols: number;
rows: number;
cwd?: string | null;
title?: string | null;
running: boolean;
lastExitCode?: number | null;
bracketedPaste: boolean;
exited: boolean;
}
export interface FileEntry {
name: string;
path: string;
isDir: boolean;
size: number;
mtime: number;
permissions?: number | null;
}
export interface TransferTask {
id: string;
hostId: string;
hostName: string;
direction: 'upload' | 'download';
filename: string;
localPath?: string | null;
remotePath: string;
totalBytes: number;
transferredBytes: number;
speedBps: number;
state: 'running' | 'completed' | 'failed';
error?: string | null;
startedAt: string;
endedAt?: string | null;
}
export interface DiskUsageItem {
mount: string;
totalBytes: number;
usedBytes: number;
}
export interface HostInspection {
hostId: string;
sampledAt: string;
uptimeSeconds: number;
load1: number;
load5: number;
load15: number;
tcpCc?: string | null;
cpuModel?: string | null;
cpuCores: number;
cpuUsagePct: number;
cpuIowaitPct: number;
cpuStealPct: number;
memTotalBytes: number;
memUsedBytes: number;
memCachedBytes: number;
swapTotalBytes: number;
swapUsedBytes: number;
diskMount: string;
diskTotalBytes: number;
diskUsedBytes: number;
disks: DiskUsageItem[];
netIface: string;
netRxBytesTotal: number;
netTxBytesTotal: number;
netRxBps: number;
netTxBps: number;
}
export const globalInspectionCache = new Map<string, HostInspection>();
export function seedInspectionMap(map: Record<string, HostInspection> | undefined | null) {
if (!map || typeof map !== 'object') return;
for (const [k, v] of Object.entries(map)) {
if (v && typeof v === 'object') {
globalInspectionCache.set(k, v);
}
}
}
+26
View File
@@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {
fontFamily: {
sans: [
'Inter',
'-apple-system',
'BlinkMacSystemFont',
'"PingFang SC"',
'"Microsoft YaHei"',
'sans-serif',
],
mono: [
'"JetBrains Mono"',
'"Cascadia Mono"',
'Consolas',
'monospace',
],
},
},
},
plugins: [],
};
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:18800',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
output: {
manualChunks: {
xterm: ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-webgl', '@xterm/addon-web-links'],
},
},
},
},
});
+1305
View File
File diff suppressed because it is too large. Load diff
+140
View File
@@ -0,0 +1,140 @@
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Key, Nonce,
};
use anyhow::{anyhow, Context, Result};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use rand::RngCore;
use scrypt::{scrypt, Params as ScryptParams};
use serde::{Deserialize, Serialize};
use std::{fs, path::Path};
const ONESHELL_FALLBACK_SECRET: &str = "change-me-before-production";
const ONESHELL_LEGACY_SALT: &[u8] = b"ai-web-terminal";
#[derive(Clone)]
pub struct MasterKey {
key: [u8; 32],
}
#[derive(Debug, Serialize, Deserialize)]
struct EncryptedBlob {
iv: String,
value: String,
}
#[derive(Debug, Deserialize)]
pub struct OneShellCipherPayload {
pub salt: Option<String>,
pub iv: Option<String>,
pub tag: Option<String>,
pub value: Option<String>,
}
impl MasterKey {
pub fn load_or_create(data_dir: &Path) -> Result<Self> {
fs::create_dir_all(data_dir)?;
let key_path = data_dir.join("secret.key");
if key_path.exists() {
let raw = fs::read_to_string(&key_path).context("读取 secret.key 失败")?;
let decoded = B64
.decode(raw.trim())
.context("解码 secret.key base64 失败")?;
if decoded.len() == 32 {
let mut key = [0u8; 32];
key.copy_from_slice(&decoded);
return Ok(Self { key });
}
}
let mut key = [0u8; 32];
rand::thread_rng().fill_bytes(&mut key);
fs::write(&key_path, B64.encode(key)).context("写入 secret.key 失败")?;
Ok(Self { key })
}
pub fn encrypt(&self, plaintext: &str) -> Result<Option<String>> {
if plaintext.is_empty() {
return Ok(None);
}
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&self.key));
let mut iv = [0u8; 12];
rand::thread_rng().fill_bytes(&mut iv);
let nonce = Nonce::from_slice(&iv);
let ciphertext = cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|_| anyhow!("AES-GCM 加密失败"))?;
let blob = EncryptedBlob {
iv: B64.encode(iv),
value: B64.encode(ciphertext),
};
Ok(Some(serde_json::to_string(&blob)?))
}
pub fn decrypt(&self, blob_json: Option<&str>) -> Result<Option<String>> {
let Some(raw) = blob_json.filter(|s| !s.trim().is_empty()) else {
return Ok(None);
};
let blob: EncryptedBlob = serde_json::from_str(raw).context("解析加密凭据 JSON 失败")?;
let iv = B64.decode(blob.iv).context("解码 IV 失败")?;
let ciphertext = B64.decode(blob.value).context("解码密文失败")?;
if iv.len() != 12 {
return Err(anyhow!("无效的 IV 长度"));
}
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&self.key));
let plaintext = cipher
.decrypt(Nonce::from_slice(&iv), ciphertext.as_ref())
.map_err(|_| anyhow!("凭据解密失败"))?;
Ok(Some(String::from_utf8(plaintext)?))
}
}
/// 解密旧版 1Shell 的 AES-256-GCM 凭据 ({ salt, iv, tag, value })
pub fn decrypt_1shell_payload(
payload: &OneShellCipherPayload,
custom_secret: Option<&str>,
) -> Result<Option<String>> {
let Some(value_b64) = payload.value.as_deref().filter(|s| !s.is_empty()) else {
return Ok(None);
};
let iv_b64 = payload
.iv
.as_deref()
.ok_or_else(|| anyhow!("1Shell 凭据缺少 iv"))?;
let tag_b64 = payload
.tag
.as_deref()
.ok_or_else(|| anyhow!("1Shell 凭据缺少 tag"))?;
let salt_bytes = match payload.salt.as_deref().filter(|s| !s.is_empty()) {
Some(s) => B64.decode(s).context("解码 1Shell salt 失败")?,
None => ONESHELL_LEGACY_SALT.to_vec(),
};
let iv = B64.decode(iv_b64).context("解码 1Shell iv 失败")?;
let tag = B64.decode(tag_b64).context("解码 1Shell tag 失败")?;
let mut ciphertext = B64.decode(value_b64).context("解码 1Shell value 失败")?;
if iv.len() != 12 || tag.len() != 16 {
return Err(anyhow!("1Shell 凭据 iv/tag 长度异常"));
}
let secret = custom_secret
.filter(|s| !s.is_empty())
.unwrap_or(ONESHELL_FALLBACK_SECRET);
// Node crypto.scryptSync 默认参数: N=16384 (log_n=14), r=8, p=1, keylen=32
let params = ScryptParams::new(14, 8, 1, 32).map_err(|_| anyhow!("无效的 scrypt 参数"))?;
let mut derived_key = [0u8; 32];
scrypt(secret.as_bytes(), &salt_bytes, &params, &mut derived_key)
.map_err(|_| anyhow!("scrypt 派生密钥失败"))?;
// aes-gcm crate 的密文末尾直接拼接 16 字节 auth tag
ciphertext.extend_from_slice(&tag);
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&derived_key));
let plaintext = cipher
.decrypt(Nonce::from_slice(&iv), ciphertext.as_ref())
.map_err(|_| anyhow!("1Shell 凭据解密校验失败"))?;
Ok(Some(String::from_utf8(plaintext)?))
}
+985
View File
@@ -0,0 +1,985 @@
use crate::{
crypto::{decrypt_1shell_payload, MasterKey, OneShellCipherPayload},
models::{
HostLink, HostMetaPatch, HostRecord, HostUpsertPayload, OsInfo, PublicHost, LOCAL_HOST_ID,
},
};
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
use serde::Deserialize;
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
sync::Mutex,
};
use uuid::Uuid;
pub struct Database {
conn: Mutex<Connection>,
master_key: MasterKey,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyOneShellHost {
id: String,
#[serde(default)]
name: String,
#[serde(rename = "type", default)]
kind: String,
#[serde(default)]
host: String,
#[serde(default)]
port: Option<u16>,
#[serde(default)]
username: String,
#[serde(default)]
auth_type: String,
#[serde(default)]
proxy_host_id: Option<String>,
#[serde(default)]
links: Vec<HostLink>,
#[serde(default)]
os_info: Option<OsInfo>,
#[serde(default)]
encrypted_password: Option<OneShellCipherPayload>,
#[serde(default)]
encrypted_private_key: Option<OneShellCipherPayload>,
#[serde(default)]
encrypted_passphrase: Option<OneShellCipherPayload>,
#[serde(default)]
created_at: Option<String>,
#[serde(default)]
updated_at: Option<String>,
}
struct LegacyPref {
repository_order: i64,
console_order: i64,
pinned: bool,
role: Option<String>,
tags: Vec<String>,
archived: bool,
}
impl Database {
pub fn open(data_dir: &Path, master_key: MasterKey) -> Result<Self> {
fs::create_dir_all(data_dir)?;
let db_path = data_dir.join("vps-switch.db");
let conn = Connection::open(&db_path).context("打开 vps-switch.db 失败")?;
conn.execute_batch(
"PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS hosts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'ssh',
addr TEXT NOT NULL DEFAULT '',
port INTEGER NOT NULL DEFAULT 22,
username TEXT NOT NULL DEFAULT 'root',
auth_type TEXT NOT NULL DEFAULT 'password',
encrypted_password TEXT,
encrypted_private_key TEXT,
encrypted_passphrase TEXT,
jump_host_id TEXT,
role TEXT,
tags_json TEXT NOT NULL DEFAULT '[]',
links_json TEXT NOT NULL DEFAULT '[]',
os_json TEXT,
pinned INTEGER NOT NULL DEFAULT 0,
favorited INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'unknown',
latency_ms INTEGER,
last_connected_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)?;
// 兼容升级已有数据库:安全添加 favorited 列
let _ = conn.execute(
"ALTER TABLE hosts ADD COLUMN favorited INTEGER NOT NULL DEFAULT 0",
[],
);
let db = Self {
conn: Mutex::new(conn),
master_key,
};
db.ensure_local_host()?;
db.ensure_default_settings()?;
Ok(db)
}
pub fn master_key(&self) -> &MasterKey {
&self.master_key
}
fn ensure_local_host(&self) -> Result<()> {
let conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?;
let exists: Option<String> = conn
.query_row(
"SELECT id FROM hosts WHERE id = ?1",
params![LOCAL_HOST_ID],
|row| row.get(0),
)
.optional()?;
if exists.is_none() {
let now = Utc::now().to_rfc3339();
let username = std::env::var("USERNAME")
.or_else(|_| std::env::var("USER"))
.unwrap_or_else(|_| "local".to_string());
let os_info = OsInfo {
os: Some("windows".to_string()),
distro_id: Some("windows".to_string()),
version_id: Some("11".to_string()),
pretty_name: Some("Windows 11 Local".to_string()),
arch: Some(std::env::consts::ARCH.to_string()),
kernel: None,
cpu_model: None,
cpu_cores: std::thread::available_parallelism()
.ok()
.map(|n| n.get() as u32),
detected_at: Some(now.clone()),
};
conn.execute(
"INSERT INTO hosts (
id, name, kind, addr, port, username, auth_type,
role, tags_json, links_json, os_json,
pinned, archived, sort_order, status, latency_ms,
last_connected_at, created_at, updated_at
) VALUES (?1, ?2, 'local', '127.0.0.1', 0, ?3, 'local',
'primary', '[]', '[]', ?4,
1, 0, -1, 'online', 0,
?5, ?5, ?5)",
params![
LOCAL_HOST_ID,
"本机",
username,
serde_json::to_string(&os_info)?,
now
],
)?;
}
Ok(())
}
pub fn count_ssh_hosts(&self) -> Result<usize> {
let conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM hosts WHERE kind = 'ssh'",
[],
|row| row.get(0),
)?;
Ok(count as usize)
}
fn row_to_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<HostRecord> {
let tags_raw: String = row.get("tags_json")?;
let links_raw: String = row.get("links_json")?;
let os_raw: Option<String> = row.get("os_json")?;
let tags: Vec<String> = serde_json::from_str(&tags_raw).unwrap_or_default();
let links: Vec<HostLink> = serde_json::from_str(&links_raw).unwrap_or_default();
let os_info: Option<OsInfo> = os_raw
.as_deref()
.and_then(|s| serde_json::from_str(s).ok());
Ok(HostRecord {
id: row.get("id")?,
name: row.get("name")?,
kind: row.get("kind")?,
addr: row.get("addr")?,
port: row.get::<_, i64>("port")? as u16,
username: row.get("username")?,
auth_type: row.get("auth_type")?,
encrypted_password: row.get("encrypted_password")?,
encrypted_private_key: row.get("encrypted_private_key")?,
encrypted_passphrase: row.get("encrypted_passphrase")?,
jump_host_id: row.get("jump_host_id")?,
role: row.get("role")?,
tags,
links,
os_info,
pinned: row.get::<_, i64>("pinned")? != 0,
favorited: row.get::<_, i64>("favorited").unwrap_or(0) != 0,
archived: row.get::<_, i64>("archived")? != 0,
sort_order: row.get("sort_order")?,
status: row.get("status")?,
latency_ms: row.get("latency_ms")?,
last_connected_at: row.get("last_connected_at")?,
last_error: row.get("last_error")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
}
pub fn list_records(&self) -> Result<Vec<HostRecord>> {
let conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?;
let mut stmt = conn.prepare(
"SELECT * FROM hosts
ORDER BY
CASE WHEN id = 'local' THEN 0 ELSE 1 END ASC,
pinned DESC,
sort_order ASC,
name COLLATE NOCASE ASC",
)?;
let rows = stmt.query_map([], Self::row_to_record)?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
pub fn list_public_hosts(&self) -> Result<Vec<PublicHost>> {
let records = self.list_records()?;
let name_map: HashMap<String, String> = records
.iter()
.map(|r| (r.id.clone(), r.name.clone()))
.collect();
Ok(records
.into_iter()
.map(|r| {
let jump_host_name = r
.jump_host_id
.as_ref()
.and_then(|jid| name_map.get(jid).cloned());
Self::to_public(r, jump_host_name)
})
.collect())
}
pub fn get_record(&self, id: &str) -> Result<Option<HostRecord>> {
let conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?;
conn.query_row("SELECT * FROM hosts WHERE id = ?1", params![id], Self::row_to_record)
.optional()
.map_err(Into::into)
}
pub fn get_public_host(&self, id: &str) -> Result<Option<PublicHost>> {
let Some(rec) = self.get_record(id)? else {
return Ok(None);
};
let jump_name = match rec.jump_host_id.as_deref() {
Some(jid) => self.get_record(jid)?.map(|j| j.name),
None => None,
};
Ok(Some(Self::to_public(rec, jump_name)))
}
fn to_public(r: HostRecord, jump_host_name: Option<String>) -> PublicHost {
PublicHost {
id: r.id,
name: r.name,
kind: r.kind,
addr: r.addr,
port: r.port,
username: r.username,
auth_type: r.auth_type,
has_password: r.encrypted_password.is_some(),
has_private_key: r.encrypted_private_key.is_some(),
has_passphrase: r.encrypted_passphrase.is_some(),
jump_host_id: r.jump_host_id,
jump_host_name,
role: r.role,
tags: r.tags,
links: r.links,
os_info: r.os_info,
pinned: r.pinned,
favorited: r.favorited,
archived: r.archived,
sort_order: r.sort_order,
status: r.status,
latency_ms: r.latency_ms,
last_connected_at: r.last_connected_at,
last_error: r.last_error,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
pub fn create_host(&self, payload: HostUpsertPayload) -> Result<PublicHost> {
let name = payload.name.trim().to_string();
let addr = payload.addr.trim().to_string();
if name.is_empty() || addr.is_empty() {
return Err(anyhow!("主机名称与地址不能为空"));
}
let id = format!("host_{}", Uuid::new_v4());
let now = Utc::now().to_rfc3339();
let enc_pwd = match payload.password.as_deref() {
Some(p) if !p.is_empty() => self.master_key.encrypt(p)?,
_ => None,
};
let enc_key = match payload.private_key.as_deref() {
Some(k) if !k.trim().is_empty() => self.master_key.encrypt(k)?,
_ => None,
};
let enc_pass = match payload.passphrase.as_deref() {
Some(p) if !p.is_empty() => self.master_key.encrypt(p)?,
_ => None,
};
let links = normalize_links(payload.links);
let tags = normalize_tags(payload.tags);
let jump_id = payload
.jump_host_id
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let role = payload
.role
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
{
let conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?;
let max_order: i64 = conn
.query_row("SELECT COALESCE(MAX(sort_order), 0) FROM hosts", [], |r| {
r.get(0)
})
.unwrap_or(0);
conn.execute(
"INSERT INTO hosts (
id, name, kind, addr, port, username, auth_type,
encrypted_password, encrypted_private_key, encrypted_passphrase,
jump_host_id, role, tags_json, links_json,
pinned, archived, sort_order, status, created_at, updated_at
) VALUES (?1, ?2, 'ssh', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, 'unknown', ?17, ?17)",
params![
id,
name,
addr,
payload.port as i64,
payload.username.trim(),
payload.auth_type,
enc_pwd,
enc_key,
enc_pass,
jump_id,
role,
serde_json::to_string(&tags)?,
serde_json::to_string(&links)?,
if payload.pinned { 1 } else { 0 },
if payload.archived { 1 } else { 0 },
max_order + 1,
now
],
)?;
}
self.get_public_host(&id)?
.ok_or_else(|| anyhow!("创建主机后读取失败"))
}
pub fn update_host(&self, id: &str, payload: HostUpsertPayload) -> Result<PublicHost> {
let existing = self
.get_record(id)?
.ok_or_else(|| anyhow!("主机不存在: {id}"))?;
let now = Utc::now().to_rfc3339();
let links = normalize_links(payload.links);
let tags = normalize_tags(payload.tags);
let role = payload
.role
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if existing.kind == "local" {
let conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?;
conn.execute(
"UPDATE hosts SET name = ?1, role = ?2, tags_json = ?3, links_json = ?4, updated_at = ?5 WHERE id = ?6",
params![
if payload.name.trim().is_empty() {
&existing.name
} else {
payload.name.trim()
},
role,
serde_json::to_string(&tags)?,
serde_json::to_string(&links)?,
now,
id
],
)?;
return self
.get_public_host(id)?
.ok_or_else(|| anyhow!("更新本机后读取失败"));
}
let enc_pwd = match payload.password.as_deref() {
Some(p) if !p.is_empty() => self.master_key.encrypt(p)?,
_ => existing.encrypted_password,
};
let enc_key = match payload.private_key.as_deref() {
Some(k) if !k.trim().is_empty() => self.master_key.encrypt(k)?,
_ => existing.encrypted_private_key,
};
let enc_pass = match payload.passphrase.as_deref() {
Some(p) if !p.is_empty() => self.master_key.encrypt(p)?,
_ => existing.encrypted_passphrase,
};
let jump_id = payload
.jump_host_id
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty() && s != id);
{
let conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?;
conn.execute(
"UPDATE hosts SET
name = ?1, addr = ?2, port = ?3, username = ?4, auth_type = ?5,
encrypted_password = ?6, encrypted_private_key = ?7, encrypted_passphrase = ?8,
jump_host_id = ?9, role = ?10, tags_json = ?11, links_json = ?12,
pinned = ?13, archived = ?14, updated_at = ?15
WHERE id = ?16",
params![
payload.name.trim(),
payload.addr.trim(),
payload.port as i64,
payload.username.trim(),
payload.auth_type,
enc_pwd,
enc_key,
enc_pass,
jump_id,
role,
serde_json::to_string(&tags)?,
serde_json::to_string(&links)?,
if payload.pinned { 1 } else { 0 },
if payload.archived { 1 } else { 0 },
now,
id
],
)?;
}
self.get_public_host(id)?
.ok_or_else(|| anyhow!("更新主机后读取失败"))
}
pub fn patch_meta(&self, id: &str, patch: HostMetaPatch) -> Result<PublicHost> {
let existing = self
.get_record(id)?
.ok_or_else(|| anyhow!("主机不存在: {id}"))?;
let now = Utc::now().to_rfc3339();
let pinned = patch.pinned.unwrap_or(existing.pinned);
let favorited = patch.favorited.unwrap_or(existing.favorited);
let archived = patch.archived.unwrap_or(existing.archived);
let role = match patch.role {
Some(r) => {
let t = r.trim().to_string();
if t.is_empty() {
None
} else {
Some(t)
}
}
None => existing.role,
};
let tags = patch.tags.map(normalize_tags).unwrap_or(existing.tags);
let links = patch.links.map(normalize_links).unwrap_or(existing.links);
{
let conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?;
conn.execute(
"UPDATE hosts SET pinned = ?1, favorited = ?2, archived = ?3, role = ?4, tags_json = ?5, links_json = ?6, updated_at = ?7 WHERE id = ?8",
params![
if pinned { 1 } else { 0 },
if favorited { 1 } else { 0 },
if archived { 1 } else { 0 },
role,
serde_json::to_string(&tags)?,
serde_json::to_string(&links)?,
now,
id
],
)?;
}
self.get_public_host(id)?
.ok_or_else(|| anyhow!("更新元数据后读取失败"))
}
pub fn reorder(&self, ordered_ids: &[String]) -> Result<()> {
let mut conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?;
let tx = conn.transaction()?;
for (idx, id) in ordered_ids.iter().enumerate() {
if id == LOCAL_HOST_ID {
continue;
}
tx.execute(
"UPDATE hosts SET sort_order = ?1 WHERE id = ?2",
params![idx as i64, id],
)?;
}
tx.commit()?;
Ok(())
}
pub fn delete_host(&self, id: &str) -> Result<()> {
if id == LOCAL_HOST_ID {
return Err(anyhow!("本机节点不能删除"));
}
let conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?;
conn.execute(
"UPDATE hosts SET jump_host_id = NULL WHERE jump_host_id = ?1",
params![id],
)?;
conn.execute("DELETE FROM hosts WHERE id = ?1", params![id])?;
Ok(())
}
pub fn update_check_result(
&self,
id: &str,
online: bool,
latency_ms: Option<i64>,
os_info: Option<OsInfo>,
error: Option<String>,
) -> Result<PublicHost> {
let existing = self
.get_record(id)?
.ok_or_else(|| anyhow!("主机不存在: {id}"))?;
let now = Utc::now().to_rfc3339();
let status = if online { "online" } else { "offline" };
let merged_os = os_info.or(existing.os_info);
let last_connected = if online {
Some(now.clone())
} else {
existing.last_connected_at
};
{
let conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?;
conn.execute(
"UPDATE hosts SET
status = ?1,
latency_ms = ?2,
os_json = ?3,
last_connected_at = ?4,
last_error = ?5,
updated_at = ?6
WHERE id = ?7",
params![
status,
latency_ms,
merged_os
.as_ref()
.and_then(|o| serde_json::to_string(o).ok()),
last_connected,
error,
now,
id
],
)?;
}
self.get_public_host(id)?
.ok_or_else(|| anyhow!("更新检测结果后读取失败"))
}
/// 从本机旧版 1Shell (%APPDATA%\1Shell\data\1shell.db) 导入全部主机与偏好
pub fn import_from_1shell(&self) -> Result<usize> {
let db_path = find_1shell_db_path()
.ok_or_else(|| anyhow!("未找到本机 1Shell 数据库 (%APPDATA%\\1Shell\\data\\1shell.db)"))?;
let src_conn = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
.with_context(|| format!("只读打开 1Shell 数据库失败: {}", db_path.display()))?;
let mut prefs: HashMap<String, LegacyPref> = HashMap::new();
if let Ok(mut pref_stmt) = src_conn.prepare(
"SELECT host_id, repository_order, console_order, pinned, role, tags_json, archived FROM host_preferences",
) {
if let Ok(rows) = pref_stmt.query_map([], |row| {
let host_id: String = row.get(0)?;
let repo_order: i64 = row.get::<_, Option<i64>>(1)?.unwrap_or(0);
let console_order: i64 = row.get::<_, Option<i64>>(2)?.unwrap_or(0);
let pinned: bool = row.get::<_, Option<i64>>(3)?.unwrap_or(0) != 0;
let role: Option<String> = row.get(4)?;
let tags_raw: Option<String> = row.get(5)?;
let archived: bool = row.get::<_, Option<i64>>(6)?.unwrap_or(0) != 0;
let tags: Vec<String> = tags_raw
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
Ok((
host_id,
LegacyPref {
repository_order: repo_order,
console_order,
pinned,
role,
tags,
archived,
},
))
}) {
for item in rows.flatten() {
prefs.insert(item.0, item.1);
}
}
}
let mut stmt = src_conn.prepare("SELECT data FROM hosts ORDER BY created_at ASC")?;
let raw_rows: Vec<String> = stmt
.query_map([], |r| r.get::<_, String>(0))?
.collect::<std::result::Result<Vec<_>, _>>()?;
let mut imported = 0usize;
let now = Utc::now().to_rfc3339();
let mut conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?;
let tx = conn.transaction()?;
for (idx, raw) in raw_rows.into_iter().enumerate() {
let Ok(legacy) = serde_json::from_str::<LegacyOneShellHost>(&raw) else {
continue;
};
if legacy.id == LOCAL_HOST_ID || legacy.kind == "local" {
continue;
}
let plain_pwd = legacy
.encrypted_password
.as_ref()
.and_then(|p| decrypt_1shell_payload(p, None).ok().flatten());
let plain_key = legacy
.encrypted_private_key
.as_ref()
.and_then(|p| decrypt_1shell_payload(p, None).ok().flatten());
let plain_pass = legacy
.encrypted_passphrase
.as_ref()
.and_then(|p| decrypt_1shell_payload(p, None).ok().flatten());
let enc_pwd = match plain_pwd.as_deref() {
Some(s) => self.master_key.encrypt(s)?,
None => None,
};
let enc_key = match plain_key.as_deref() {
Some(s) => self.master_key.encrypt(s)?,
None => None,
};
let enc_pass = match plain_pass.as_deref() {
Some(s) => self.master_key.encrypt(s)?,
None => None,
};
let pref = prefs.get(&legacy.id);
let sort_order = pref
.map(|p| {
if p.repository_order > 0 {
p.repository_order
} else if p.console_order > 0 {
p.console_order
} else {
idx as i64
}
})
.unwrap_or(idx as i64);
let pinned = pref.map(|p| p.pinned).unwrap_or(false);
let archived = pref.map(|p| p.archived).unwrap_or(false);
let role = pref.and_then(|p| p.role.clone());
let tags = pref.map(|p| p.tags.clone()).unwrap_or_default();
let links = normalize_links(legacy.links);
let os_json = legacy
.os_info
.as_ref()
.and_then(|o| serde_json::to_string(o).ok());
let created_at = legacy.created_at.unwrap_or_else(|| now.clone());
let updated_at = legacy.updated_at.unwrap_or_else(|| now.clone());
tx.execute(
"INSERT INTO hosts (
id, name, kind, addr, port, username, auth_type,
encrypted_password, encrypted_private_key, encrypted_passphrase,
jump_host_id, role, tags_json, links_json, os_json,
pinned, archived, sort_order, status, created_at, updated_at
) VALUES (?1, ?2, 'ssh', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, 'unknown', ?18, ?19)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
addr = excluded.addr,
port = excluded.port,
username = excluded.username,
auth_type = excluded.auth_type,
encrypted_password = COALESCE(excluded.encrypted_password, hosts.encrypted_password),
encrypted_private_key = COALESCE(excluded.encrypted_private_key, hosts.encrypted_private_key),
encrypted_passphrase = COALESCE(excluded.encrypted_passphrase, hosts.encrypted_passphrase),
jump_host_id = excluded.jump_host_id,
role = COALESCE(hosts.role, excluded.role),
os_json = COALESCE(hosts.os_json, excluded.os_json),
updated_at = excluded.updated_at",
params![
legacy.id,
legacy.name,
legacy.host,
legacy.port.unwrap_or(22) as i64,
if legacy.username.is_empty() {
"root".to_string()
} else {
legacy.username
},
if legacy.auth_type.is_empty() {
"password".to_string()
} else {
legacy.auth_type
},
enc_pwd,
enc_key,
enc_pass,
legacy.proxy_host_id.filter(|s| !s.is_empty()),
role,
serde_json::to_string(&tags)?,
serde_json::to_string(&links)?,
os_json,
if pinned { 1 } else { 0 },
if archived { 1 } else { 0 },
sort_order,
created_at,
updated_at
],
)?;
imported += 1;
}
tx.commit()?;
Ok(imported)
}
// ==================== 系统设置、Web 登录鉴权与 MCP API Key ====================
fn ensure_default_settings(&self) -> Result<()> {
let conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?;
let has_key: Option<String> = conn
.query_row(
"SELECT value FROM settings WHERE key = 'mcp_api_key'",
[],
|r| r.get(0),
)
.optional()?;
if has_key.is_none() {
let api_key = generate_api_key();
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES ('mcp_api_key', ?1)",
params![api_key],
)?;
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES ('mcp_enabled', 'true')",
[],
)?;
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES ('mcp_require_api_key', 'false')",
[],
)?;
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES ('auth_enabled', 'false')",
[],
)?;
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES ('admin_username', 'admin')",
[],
)?;
}
Ok(())
}
pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
let conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?;
conn.query_row(
"SELECT value FROM settings WHERE key = ?1",
params![key],
|r| r.get(0),
)
.optional()
.map_err(Into::into)
}
pub fn set_setting(&self, key: &str, value: &str) -> Result<()> {
let conn = self.conn.lock().map_err(|_| anyhow!("数据库锁异常"))?;
conn.execute(
"INSERT INTO settings (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value],
)?;
Ok(())
}
pub fn get_app_settings(&self) -> Result<AppSettings> {
let auth_enabled = self
.get_setting("auth_enabled")?
.map(|v| v == "true")
.unwrap_or(false);
let admin_username = self
.get_setting("admin_username")?
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "admin".to_string());
let has_admin_password = self
.get_setting("admin_password_enc")?
.filter(|s| !s.is_empty())
.is_some();
let mcp_enabled = self
.get_setting("mcp_enabled")?
.map(|v| v != "false")
.unwrap_or(true);
let mcp_require_api_key = self
.get_setting("mcp_require_api_key")?
.map(|v| v == "true")
.unwrap_or(false);
let mcp_api_key = match self.get_setting("mcp_api_key")? {
Some(k) if !k.is_empty() => k,
_ => {
let k = generate_api_key();
self.set_setting("mcp_api_key", &k)?;
k
}
};
Ok(AppSettings {
auth_enabled,
admin_username,
has_admin_password,
mcp_enabled,
mcp_require_api_key,
mcp_api_key,
})
}
pub fn update_auth_settings(
&self,
auth_enabled: bool,
username: &str,
new_password: Option<&str>,
) -> Result<AppSettings> {
let clean_user = username.trim();
if clean_user.is_empty() {
return Err(anyhow!("管理员账号不能为空"));
}
if let Some(pwd) = new_password.filter(|s| !s.is_empty()) {
if let Some(enc) = self.master_key.encrypt(pwd)? {
self.set_setting("admin_password_enc", &enc)?;
}
}
if auth_enabled {
let has_pwd = self
.get_setting("admin_password_enc")?
.filter(|s| !s.is_empty())
.is_some();
if !has_pwd {
return Err(anyhow!("开启登录保护前请先设置管理员密码"));
}
}
self.set_setting("admin_username", clean_user)?;
self.set_setting("auth_enabled", if auth_enabled { "true" } else { "false" })?;
self.get_app_settings()
}
pub fn verify_admin_credentials(&self, username: &str, password: &str) -> Result<bool> {
let expected_user = self
.get_setting("admin_username")?
.unwrap_or_else(|| "admin".to_string());
if username.trim() != expected_user {
return Ok(false);
}
let enc = self.get_setting("admin_password_enc")?;
let Some(stored_pwd) = self.master_key.decrypt(enc.as_deref())? else {
return Ok(false);
};
Ok(stored_pwd == password)
}
pub fn update_mcp_settings(
&self,
mcp_enabled: bool,
mcp_require_api_key: bool,
regenerate_key: bool,
) -> Result<AppSettings> {
self.set_setting("mcp_enabled", if mcp_enabled { "true" } else { "false" })?;
self.set_setting(
"mcp_require_api_key",
if mcp_require_api_key { "true" } else { "false" },
)?;
if regenerate_key {
let new_key = generate_api_key();
self.set_setting("mcp_api_key", &new_key)?;
}
self.get_app_settings()
}
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppSettings {
pub auth_enabled: bool,
pub admin_username: String,
pub has_admin_password: bool,
pub mcp_enabled: bool,
pub mcp_require_api_key: bool,
pub mcp_api_key: String,
}
fn generate_api_key() -> String {
format!(
"vpsk_{}{}",
Uuid::new_v4().simple(),
&Uuid::new_v4().simple().to_string()[..12]
)
}
fn find_1shell_db_path() -> Option<PathBuf> {
if let Ok(appdata) = std::env::var("APPDATA") {
let candidate = PathBuf::from(appdata).join("1Shell").join("data").join("1shell.db");
if candidate.exists() {
return Some(candidate);
}
}
let fallback = PathBuf::from(r"E:\1shell4.7\1shell-4.7\data\1shell.db");
if fallback.exists() {
return Some(fallback);
}
None
}
fn normalize_links(links: Vec<HostLink>) -> Vec<HostLink> {
links
.into_iter()
.filter(|l| !l.name.trim().is_empty() && !l.url.trim().is_empty())
.map(|l| HostLink {
id: if l.id.trim().is_empty() {
format!("link_{}", Uuid::new_v4())
} else {
l.id.trim().to_string()
},
name: l.name.trim().to_string(),
url: l.url.trim().to_string(),
description: l.description.trim().to_string(),
})
.collect()
}
fn normalize_tags(tags: Vec<String>) -> Vec<String> {
let mut out = Vec::new();
for t in tags {
let trimmed = t.trim().to_string();
if !trimmed.is_empty() && !out.contains(&trimmed) {
out.push(trimmed);
}
}
out
}
+265
View File
@@ -0,0 +1,265 @@
#![cfg(windows)]
use crate::{api::AppState, terminal::UiEvent};
use anyhow::Result;
use std::sync::Arc;
use tao::{
dpi::{LogicalSize, Size},
event::{Event, StartCause, WindowEvent},
event_loop::{ControlFlow, EventLoopBuilder},
window::{Icon as WindowIcon, WindowBuilder},
};
use tray_icon::{
menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem},
Icon as TrayIconImage, MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent,
};
use wry::WebViewBuilder;
#[derive(Debug, Clone)]
pub enum DesktopUserEvent {
TrayIcon(TrayIconEvent),
TrayMenu(MenuEvent),
ShowWindow,
}
const MENU_ID_SHOW: &str = "app:show";
const MENU_ID_QUIT: &str = "app:quit";
const MENU_HOST_PREFIX: &str = "host:";
/// 纯代码光栅化生成 64x64 高清抗锯齿 VPS Switch 图标(深色圆角矩形 + 白色 > 终端符 + 活力橙圆点)
pub fn render_brand_icon_rgba(size: u32) -> Vec<u8> {
let mut buf = vec![0u8; (size * size * 4) as usize];
let s = size as f32;
for y in 0..size {
for x in 0..size {
let fx = (x as f32 + 0.5) / s * 64.0;
let fy = (y as f32 + 0.5) / s * 64.0;
// 1. 圆角方块底 (64x64, rx=15)
let bg_dist = sd_rounded_box(fx - 32.0, fy - 32.0, 29.0, 29.0, 14.0);
if bg_dist > 1.0 {
continue;
}
let bg_alpha = (0.5 - bg_dist).clamp(0.0, 1.0);
let mut r = 24.0;
let mut g = 24.0;
let mut b = 27.0;
// 2. 左侧白色终端折线 > : (16, 20) -> (28, 32) -> (16, 44)
let d_seg1 = sd_segment(fx, fy, 16.0, 20.0, 27.5, 32.0) - 2.7;
let d_seg2 = sd_segment(fx, fy, 27.5, 32.0, 16.0, 44.0) - 2.7;
let d_chevron = d_seg1.min(d_seg2);
if d_chevron < 0.8 {
let w = (0.5 - d_chevron).clamp(0.0, 1.0);
r = r * (1.0 - w) + 250.0 * w;
g = g * (1.0 - w) + 250.0 * w;
b = b * (1.0 - w) + 250.0 * w;
}
// 3. 右侧活力橙 Switch 节点圆 (cx=43, cy=32, r=6.8)
let d_dot = ((fx - 43.0).powi(2) + (fy - 32.0).powi(2)).sqrt() - 6.8;
if d_dot < 0.8 {
let w = (0.5 - d_dot).clamp(0.0, 1.0);
r = r * (1.0 - w) + 249.0 * w;
g = g * (1.0 - w) + 115.0 * w;
b = b * (1.0 - w) + 22.0 * w;
}
let idx = ((y * size + x) * 4) as usize;
buf[idx] = r as u8;
buf[idx + 1] = g as u8;
buf[idx + 2] = b as u8;
buf[idx + 3] = (bg_alpha * 255.0) as u8;
}
}
buf
}
fn sd_rounded_box(px: f32, py: f32, bx: f32, by: f32, r: f32) -> f32 {
let qx = px.abs() - bx + r;
let qy = py.abs() - by + r;
(qx.max(0.0).powi(2) + qy.max(0.0).powi(2)).sqrt() + qx.max(qy).min(0.0) - r
}
fn sd_segment(px: f32, py: f32, ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
let pax = px - ax;
let pay = py - ay;
let bax = bx - ax;
let bay = by - ay;
let h = ((pax * bax + pay * bay) / (bax * bax + bay * bay)).clamp(0.0, 1.0);
((pax - bax * h).powi(2) + (pay - bay * h).powi(2)).sqrt()
}
fn build_tray_menu(state: &Arc<AppState>) -> Result<Menu> {
let menu = Menu::new();
let show_item = MenuItem::with_id(MENU_ID_SHOW, "打开 VPS Switch 主界面", true, None);
menu.append(&show_item)?;
menu.append(&PredefinedMenuItem::separator())?;
if let Ok(hosts) = state.db.list_public_hosts() {
let mut active: Vec<_> = hosts.into_iter().filter(|h| !h.archived).collect();
// 优先排:本机 -> 收藏 -> 置顶 -> 在线
active.sort_by(|a, b| {
(a.kind != "local")
.cmp(&(b.kind != "local"))
.then_with(|| b.favorited.cmp(&a.favorited))
.then_with(|| b.pinned.cmp(&a.pinned))
.then_with(|| (a.status != "online").cmp(&(b.status != "online")))
});
for h in active.into_iter().take(28) {
let prefix = if h.favorited {
"★ "
} else if h.status == "online" {
"● "
} else {
"○ "
};
let label = if h.kind == "local" {
format!("{prefix}{} (PowerShell)", h.name)
} else {
format!("{prefix}{} ({})", h.name, h.addr)
};
let item = MenuItem::with_id(
format!("{MENU_HOST_PREFIX}{}", h.id),
label,
true,
None,
);
let _ = menu.append(&item);
}
menu.append(&PredefinedMenuItem::separator())?;
}
let quit_item = MenuItem::with_id(MENU_ID_QUIT, "退出 VPS Switch", true, None);
menu.append(&quit_item)?;
Ok(menu)
}
pub fn run_desktop_window(
state: Arc<AppState>,
rt_handle: tokio::runtime::Handle,
port: u16,
) -> Result<()> {
let event_loop = EventLoopBuilder::<DesktopUserEvent>::with_user_event().build();
let proxy = event_loop.create_proxy();
// 注册外部单实例唤醒回调(当用户第二次双击 vps-switch.exe 时直接唤醒当前窗口)
{
let proxy_show = proxy.clone();
let mut rx = state.terminals.subscribe_ui_events();
rt_handle.spawn(async move {
while let Ok(ev) = rx.recv().await {
match ev {
UiEvent::RevealTerminal { .. } | UiEvent::ShowMainWindow => {
let _ = proxy_show.send_event(DesktopUserEvent::ShowWindow);
}
}
}
});
}
let rgba64 = render_brand_icon_rgba(64);
let win_icon = WindowIcon::from_rgba(rgba64.clone(), 64, 64).ok();
let tray_icon_img = TrayIconImage::from_rgba(rgba64, 64, 64)?;
let window = WindowBuilder::new()
.with_title("VPS Switch")
.with_inner_size(Size::Logical(LogicalSize::new(1280.0, 800.0)))
.with_min_inner_size(Size::Logical(LogicalSize::new(920.0, 600.0)))
.with_window_icon(win_icon)
.build(&event_loop)?;
let url = format!("http://127.0.0.1:{port}");
let _webview = WebViewBuilder::new()
.with_url(&url)
.build(&window)?;
// 系统托盘事件转发到主事件循环
let proxy_tray = proxy.clone();
TrayIconEvent::set_event_handler(Some(move |event| {
let _ = proxy_tray.send_event(DesktopUserEvent::TrayIcon(event));
}));
let proxy_menu = proxy.clone();
MenuEvent::set_event_handler(Some(move |event| {
let _ = proxy_menu.send_event(DesktopUserEvent::TrayMenu(event));
}));
let mut tray_icon = None;
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Wait;
match event {
Event::NewEvents(StartCause::Init) => {
if let Ok(menu) = build_tray_menu(&state) {
tray_icon = TrayIconBuilder::new()
.with_menu(Box::new(menu))
.with_menu_on_left_click(false)
.with_tooltip("VPS Switch · 右键快速连接 VPS 终端")
.with_icon(tray_icon_img.clone())
.build()
.ok();
}
}
Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} => {
// 点击右上角 × 最小化驻留到系统托盘,保持后台 MCP Server 与已打开的终端不断连
window.set_visible(false);
}
Event::UserEvent(DesktopUserEvent::ShowWindow) => {
window.set_visible(true);
window.set_minimized(false);
window.set_focus();
}
Event::UserEvent(DesktopUserEvent::TrayIcon(tray_ev)) => {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = tray_ev
{
window.set_visible(true);
window.set_minimized(false);
window.set_focus();
}
}
Event::UserEvent(DesktopUserEvent::TrayMenu(menu_ev)) => {
let id_str = menu_ev.id.0.as_str();
if id_str == MENU_ID_SHOW {
window.set_visible(true);
window.set_minimized(false);
window.set_focus();
} else if id_str == MENU_ID_QUIT {
let _ = tray_icon.take();
*control_flow = ControlFlow::Exit;
} else if let Some(host_id) = id_str.strip_prefix(MENU_HOST_PREFIX) {
// 托盘右键点选某台 VPS:立即唤醒主窗口并在界面上直接打开该主机的终端!
window.set_visible(true);
window.set_minimized(false);
window.set_focus();
let st = state.clone();
let hid = host_id.to_string();
rt_handle.spawn(async move {
if let Some(existing) = st.terminals.find_active_pane_for_host(&hid) {
st.terminals.reveal_pane_in_ui(&existing.id, None);
} else if let Ok(created) = st
.terminals
.create_pane(&st.ssh_pool, &st.db, &hid, 100, 30, None, false)
.await
{
st.terminals.reveal_pane_in_ui(&created.id, None);
}
});
}
}
_ => {}
}
});
}
+1061
View File
File diff suppressed because it is too large. Load diff
+609
View File
@@ -0,0 +1,609 @@
use crate::{
db::Database,
models::{OsInfo, LOCAL_HOST_ID},
ssh::{exec_short_command, SshPool},
};
use anyhow::{anyhow, Result};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
sync::Mutex,
time::Instant,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DiskUsageItem {
pub mount: String,
pub total_bytes: u64,
pub used_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HostInspection {
pub host_id: String,
pub sampled_at: String,
pub uptime_seconds: u64,
pub load_1: f64,
pub load_5: f64,
pub load_15: f64,
pub tcp_cc: Option<String>,
pub cpu_model: Option<String>,
pub cpu_cores: u32,
pub cpu_usage_pct: f64,
pub cpu_iowait_pct: f64,
pub cpu_steal_pct: f64,
pub mem_total_bytes: u64,
pub mem_used_bytes: u64,
pub mem_cached_bytes: u64,
pub swap_total_bytes: u64,
pub swap_used_bytes: u64,
pub disk_mount: String,
pub disk_total_bytes: u64,
pub disk_used_bytes: u64,
pub disks: Vec<DiskUsageItem>,
pub net_iface: String,
pub net_rx_bytes_total: u64,
pub net_tx_bytes_total: u64,
pub net_rx_bps: u64,
pub net_tx_bps: u64,
}
#[derive(Clone, Copy)]
struct CpuTicks {
user: u64,
nice: u64,
system: u64,
idle: u64,
iowait: u64,
irq: u64,
softirq: u64,
steal: u64,
}
impl CpuTicks {
fn total(&self) -> u64 {
self.user
+ self.nice
+ self.system
+ self.idle
+ self.iowait
+ self.irq
+ self.softirq
+ self.steal
}
fn idle_all(&self) -> u64 {
self.idle + self.iowait
}
}
struct PrevHostSample {
at: Instant,
cpu: CpuTicks,
net_iface: String,
net_rx: u64,
net_tx: u64,
last_cpu_pct: f64,
last_iowait_pct: f64,
last_steal_pct: f64,
}
#[derive(Default)]
pub struct InspectorEngine {
prev_samples: Mutex<HashMap<String, PrevHostSample>>,
latest_snapshots: Mutex<HashMap<String, HostInspection>>,
}
/// 纯内核虚拟文件读取脚本:
/// 1. 开头先用纯 shell builtin `read < /proc/stat` 取样 CPU1,在读取其他内核文件后再取 CPU2,
/// 保证即使首次点开也能通过真实 `/proc/stat` 差分算出准确 CPU%,绝不用容器里失真的 loadavg 估算;
/// 2. 磁盘统一使用 POSIX 标准 `df -Pk`(1024-byte blocks,兼容 Alpine BusyBox 与所有发行版)。
const LINUX_INSPECT_SCRIPT: &str = r#"export LC_ALL=C
read -r _ u1 n1 s1 i1 w1 q1 sq1 st1 _ < /proc/stat 2>/dev/null
CPU1="$u1 $n1 $s1 $i1 $w1 $q1 $sq1 $st1"
read -r UP _ < /proc/uptime 2>/dev/null || UP=0
read -r L1 L5 L15 _ < /proc/loadavg 2>/dev/null || { L1=0; L5=0; L15=0; }
CC=$(cat /proc/sys/net/ipv4/tcp_congestion_control 2>/dev/null || echo "")
IFACE=$(awk '$2 == "00000000" {print $1; exit}' /proc/net/route 2>/dev/null)
if [ -z "$IFACE" ]; then
IFACE=$(awk -F: 'NR>2 {gsub(/^[ \t]+/,"",$1); if ($1 !~ /^(lo|docker|veth|br-|tun|tap|wg|tailscale|flannel|cni|virbr)/) {print $1; exit}}' /proc/net/dev 2>/dev/null)
fi
NET=$(awk -F'[: ]+' -v dev="$IFACE" '$0 ~ "^[ \t]*"dev":" {
for(i=1;i<=NF;i++) if($i == dev) { print $(i+1), $(i+9); exit }
}' /proc/net/dev 2>/dev/null)
MEM=$(awk '
/^MemTotal:/ {mt=$2}
/^MemFree:/ {mf=$2}
/^MemAvailable:/ {ma=$2}
/^Buffers:/ {buf=$2}
/^Cached:/ {ca=$2}
/^SReclaimable:/ {sr=$2}
/^SwapTotal:/ {st=$2}
/^SwapFree:/ {sf=$2}
END {
avail = (ma > 0) ? ma : (mf + buf + ca);
used = (mt > avail) ? (mt - avail) : 0;
cache = buf + ca + sr;
sw_used = (st > sf) ? (st - sf) : 0;
printf "%d %d %d %d %d", mt*1024, used*1024, cache*1024, st*1024, sw_used*1024
}
' /proc/meminfo 2>/dev/null)
DISKS=$(df -Pk 2>/dev/null | awk 'NR>1 && $1 !~ /^(tmpfs|devtmpfs|udev|none|shm|overlay)$/ && $6 !~ /^\/(dev|proc|sys|run|boot\/efi)/ {printf "%s:%d:%d|", $6, $2*1024, $3*1024}')
if [ -z "$DISKS" ]; then
DISKS=$(df -Pk / 2>/dev/null | awk 'NR==2 {printf "%s:%d:%d|", $6, $2*1024, $3*1024}')
fi
MODEL=$(awk -F: '/model name|Model name/ {gsub(/^[ \t]+/,"",$2); print $2; exit}' /proc/cpuinfo 2>/dev/null)
CORES=$(grep -c '^processor' /proc/cpuinfo 2>/dev/null || echo 1)
if [ -f /etc/os-release ]; then . /etc/os-release; fi
UNAME_R=$(uname -r 2>/dev/null)
UNAME_M=$(uname -m 2>/dev/null)
sleep 0.12 2>/dev/null || true
read -r _ u2 n2 s2 i2 w2 q2 sq2 st2 _ < /proc/stat 2>/dev/null
CPU2="$u2 $n2 $s2 $i2 $w2 $q2 $sq2 $st2"
printf "UP=%s\nLOAD=%s %s %s\nCC=%s\nCPU1=%s\nCPU2=%s\nIFACE=%s\nNET=%s\nMEM=%s\nDISKS=%s\nMODEL=%s\nCORES=%s\nOS_ID=%s\nOS_VER=%s\nOS_PRETTY=%s\nKERNEL=%s\nARCH=%s\n" \
"$UP" "$L1" "$L5" "$L15" "$CC" "$CPU1" "$CPU2" "$IFACE" "$NET" "$MEM" "$DISKS" "$MODEL" "$CORES" "${ID:-linux}" "${VERSION_ID:-}" "${PRETTY_NAME:-Linux}" "$UNAME_R" "$UNAME_M"
"#;
impl InspectorEngine {
pub fn new() -> Self {
Self::default()
}
pub fn get_all_cached(&self) -> HashMap<String, HostInspection> {
self.latest_snapshots.lock().unwrap().clone()
}
pub async fn inspect_host(
&self,
pool: &SshPool,
db: &Database,
host_id: &str,
) -> Result<HostInspection> {
if host_id == LOCAL_HOST_ID {
let ins = self.inspect_local_windows().await?;
self.latest_snapshots
.lock()
.unwrap()
.insert(host_id.to_string(), ins.clone());
return Ok(ins);
}
let record = db
.get_record(host_id)?
.ok_or_else(|| anyhow!("主机不存在: {host_id}"))?;
let jump = match record.jump_host_id.as_deref() {
Some(jid) => db.get_record(jid)?,
None => None,
};
let started = Instant::now();
let session = match pool
.get_or_connect(&record, jump.as_ref(), db.master_key())
.await
{
Ok(s) => s,
Err(err) => {
let _ = db.update_check_result(
host_id,
false,
None,
None,
Some(format!("{err:#}")),
);
return Err(err);
}
};
let raw = match exec_short_command(&session, LINUX_INSPECT_SCRIPT).await {
Ok(r) => r,
Err(err) => {
pool.invalidate_host(host_id).await;
let _ = db.update_check_result(
host_id,
false,
None,
None,
Some(format!("{err:#}")),
);
return Err(err);
}
};
let latency_ms = started.elapsed().as_millis() as i64;
let (inspection, os_info) = self.parse_linux_inspection(host_id, &raw)?;
let _ = db.update_check_result(host_id, true, Some(latency_ms), Some(os_info), None);
self.latest_snapshots
.lock()
.unwrap()
.insert(host_id.to_string(), inspection.clone());
Ok(inspection)
}
fn parse_linux_inspection(
&self,
host_id: &str,
raw: &str,
) -> Result<(HostInspection, OsInfo)> {
let mut map = HashMap::new();
for line in raw.lines() {
if let Some((k, v)) = line.split_once('=') {
map.insert(k.trim(), v.trim());
}
}
let uptime_seconds = map
.get("UP")
.and_then(|s| s.split('.').next())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let mut load_parts = map
.get("LOAD")
.copied()
.unwrap_or("0 0 0")
.split_whitespace();
let load_1 = load_parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
let load_5 = load_parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
let load_15 = load_parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
let tcp_cc = map
.get("CC")
.map(|s| s.to_string())
.filter(|s| !s.is_empty());
let cpu1 = parse_cpu_ticks(map.get("CPU1").copied().unwrap_or(""));
let cpu2 = parse_cpu_ticks(map.get("CPU2").copied().unwrap_or(""));
let net_iface = map
.get("IFACE")
.copied()
.filter(|s| !s.is_empty())
.unwrap_or("eth0")
.to_string();
let mut net_parts = map.get("NET").copied().unwrap_or("0 0").split_whitespace();
let net_rx = net_parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
let net_tx = net_parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
let mut mem_parts = map
.get("MEM")
.copied()
.unwrap_or("0 0 0 0 0")
.split_whitespace();
let mem_total_bytes = mem_parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let mem_used_bytes = mem_parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let mem_cached_bytes = mem_parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let swap_total_bytes = mem_parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let swap_used_bytes = mem_parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let mut disks = Vec::new();
let mut seen_mounts = std::collections::HashSet::new();
for item in map.get("DISKS").copied().unwrap_or("").split('|') {
if item.trim().is_empty() {
continue;
}
let mut p = item.split(':');
if let (Some(mount), Some(tot), Some(used)) = (
p.next(),
p.next().and_then(|s| s.parse::<u64>().ok()),
p.next().and_then(|s| s.parse::<u64>().ok()),
) {
if tot > 0 && seen_mounts.insert(mount.to_string()) {
disks.push(DiskUsageItem {
mount: mount.to_string(),
total_bytes: tot,
used_bytes: used,
});
}
}
}
disks.sort_by(|a, b| {
(a.mount != "/")
.cmp(&(b.mount != "/"))
.then_with(|| a.mount.cmp(&b.mount))
});
let primary_disk = disks.first().cloned().unwrap_or(DiskUsageItem {
mount: "/".to_string(),
total_bytes: 0,
used_bytes: 0,
});
let cpu_model = map
.get("MODEL")
.copied()
.filter(|s| !s.is_empty())
.map(str::to_string);
let cpu_cores = map
.get("CORES")
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(1)
.max(1);
let now_inst = Instant::now();
let (cpu_usage_pct, cpu_iowait_pct, cpu_steal_pct, net_rx_bps, net_tx_bps) = {
let mut guard = self.prev_samples.lock().unwrap();
if let Some(prev) = guard.get(host_id) {
let elapsed = now_inst.duration_since(prev.at).as_secs_f64();
// 若距离上次采样在 15 秒内,直接用跨周期的精确硬件计数差值;否则使用本次脚本内 CPU1->CPU2 的差值
let base_cpu = if elapsed <= 15.0 { prev.cpu } else { cpu1 };
let (cpu_pct, iowait_pct, steal_pct) = compute_cpu_diff(&base_cpu, &cpu2)
.unwrap_or((prev.last_cpu_pct, prev.last_iowait_pct, prev.last_steal_pct));
let (rx_bps, tx_bps) = if elapsed > 0.2 && elapsed <= 15.0 && prev.net_iface == net_iface {
let drx = net_rx.saturating_sub(prev.net_rx);
let dtx = net_tx.saturating_sub(prev.net_tx);
((drx as f64 / elapsed) as u64, (dtx as f64 / elapsed) as u64)
} else {
(0, 0)
};
guard.insert(
host_id.to_string(),
PrevHostSample {
at: now_inst,
cpu: cpu2,
net_iface: net_iface.clone(),
net_rx,
net_tx,
last_cpu_pct: cpu_pct,
last_iowait_pct: iowait_pct,
last_steal_pct: steal_pct,
},
);
(cpu_pct, iowait_pct, steal_pct, rx_bps, tx_bps)
} else {
// 首次采样:直接用脚本内 CPU1 -> CPU2 的真实 /proc/stat 差值,绝不用 loadavg 瞎猜!
let (cpu_pct, iowait_pct, steal_pct) =
compute_cpu_diff(&cpu1, &cpu2).unwrap_or((0.0, 0.0, 0.0));
guard.insert(
host_id.to_string(),
PrevHostSample {
at: now_inst,
cpu: cpu2,
net_iface: net_iface.clone(),
net_rx,
net_tx,
last_cpu_pct: cpu_pct,
last_iowait_pct: iowait_pct,
last_steal_pct: steal_pct,
},
);
(cpu_pct, iowait_pct, steal_pct, 0, 0)
}
};
let now_iso = Utc::now().to_rfc3339();
let os_info = OsInfo {
os: Some("linux".to_string()),
distro_id: map.get("OS_ID").map(|s| s.to_lowercase()),
version_id: map.get("OS_VER").filter(|s| !s.is_empty()).map(|s| s.to_string()),
pretty_name: map.get("OS_PRETTY").filter(|s| !s.is_empty()).map(|s| s.to_string()),
arch: map.get("ARCH").filter(|s| !s.is_empty()).map(|s| s.to_string()),
kernel: map.get("KERNEL").filter(|s| !s.is_empty()).map(|s| s.to_string()),
cpu_model: cpu_model.clone(),
cpu_cores: Some(cpu_cores),
detected_at: Some(now_iso.clone()),
};
let inspection = HostInspection {
host_id: host_id.to_string(),
sampled_at: now_iso,
uptime_seconds,
load_1,
load_5,
load_15,
tcp_cc,
cpu_model,
cpu_cores,
cpu_usage_pct: (cpu_usage_pct * 10.0).round() / 10.0,
cpu_iowait_pct: (cpu_iowait_pct * 10.0).round() / 10.0,
cpu_steal_pct: (cpu_steal_pct * 10.0).round() / 10.0,
mem_total_bytes,
mem_used_bytes,
mem_cached_bytes,
swap_total_bytes,
swap_used_bytes,
disk_mount: primary_disk.mount,
disk_total_bytes: primary_disk.total_bytes,
disk_used_bytes: primary_disk.used_bytes,
disks,
net_iface,
net_rx_bytes_total: net_rx,
net_tx_bytes_total: net_tx,
net_rx_bps,
net_tx_bps,
};
Ok((inspection, os_info))
}
async fn inspect_local_windows(&self) -> Result<HostInspection> {
let ps_script = r#"
$os = Get-CimInstance Win32_OperatingSystem
$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
$disks = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object { "$($_.DeviceID)/:$($_.Size):$($_.Size - $_.FreeSpace)" }
$net = Get-NetAdapterStatistics | Where-Object { $_.ReceivedBytes -gt 0 } | Sort-Object ReceivedBytes -Descending | Select-Object -First 1
$up = [int]((Get-Date) - $os.LastBootUpTime).TotalSeconds
$mt = [uint64]$os.TotalVisibleMemorySize * 1024
$mf = [uint64]$os.FreePhysicalMemory * 1024
$mu = if ($mt -gt $mf) { $mt - $mf } else { 0 }
Write-Output "UP=$up"
Write-Output "CPU_PCT=$($cpu.LoadPercentage)"
Write-Output "CPU_NAME=$($cpu.Name)"
Write-Output "CORES=$([Environment]::ProcessorCount)"
Write-Output "MEM=$mt $mu"
Write-Output "DISKS=$($disks -join '|')"
Write-Output "NET=$($net.Name)|$($net.ReceivedBytes)|$($net.SentBytes)"
"#;
let out = tokio::process::Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-Command", ps_script])
.output()
.await?;
let text = String::from_utf8_lossy(&out.stdout);
let mut map = HashMap::new();
for line in text.lines() {
if let Some((k, v)) = line.split_once('=') {
map.insert(k.trim(), v.trim());
}
}
let uptime_seconds = map.get("UP").and_then(|s| s.parse().ok()).unwrap_or(0);
let cpu_usage_pct = map.get("CPU_PCT").and_then(|s| s.parse().ok()).unwrap_or(0.0);
let cpu_model = map.get("CPU_NAME").map(|s| s.to_string());
let cpu_cores = map.get("CORES").and_then(|s| s.parse().ok()).unwrap_or(8);
let mut mem_it = map.get("MEM").copied().unwrap_or("0 0").split_whitespace();
let mem_total_bytes = mem_it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let mem_used_bytes = mem_it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let mut disks = Vec::new();
for item in map.get("DISKS").copied().unwrap_or("").split('|') {
if item.trim().is_empty() {
continue;
}
let mut p = item.split(':');
// Windows mount has C:/ so first two parts are "C" and "/"
let parts: Vec<&str> = item.split(':').collect();
if parts.len() >= 4 {
let mount = format!("{}:{}", parts[0], parts[1]);
let tot = parts[2].parse::<u64>().unwrap_or(0);
let used = parts[3].parse::<u64>().unwrap_or(0);
if tot > 0 {
disks.push(DiskUsageItem {
mount,
total_bytes: tot,
used_bytes: used,
});
}
} else if let (Some(m), Some(tot), Some(used)) = (
p.next(),
p.next().and_then(|s| s.parse::<u64>().ok()),
p.next().and_then(|s| s.parse::<u64>().ok()),
) {
if tot > 0 {
disks.push(DiskUsageItem {
mount: m.to_string(),
total_bytes: tot,
used_bytes: used,
});
}
}
}
let primary_disk = disks.first().cloned().unwrap_or(DiskUsageItem {
mount: "C:/".to_string(),
total_bytes: 0,
used_bytes: 0,
});
let mut net_it = map.get("NET").copied().unwrap_or("Ethernet|0|0").split('|');
let net_iface = net_it.next().unwrap_or("Ethernet").to_string();
let net_rx: u64 = net_it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let net_tx: u64 = net_it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let now_inst = Instant::now();
let (net_rx_bps, net_tx_bps) = {
let mut guard = self.prev_samples.lock().unwrap();
let rates = if let Some(prev) = guard.get(LOCAL_HOST_ID) {
let elapsed = now_inst.duration_since(prev.at).as_secs_f64();
if elapsed > 0.2 {
(
(net_rx.saturating_sub(prev.net_rx) as f64 / elapsed) as u64,
(net_tx.saturating_sub(prev.net_tx) as f64 / elapsed) as u64,
)
} else {
(0, 0)
}
} else {
(0, 0)
};
guard.insert(
LOCAL_HOST_ID.to_string(),
PrevHostSample {
at: now_inst,
cpu: CpuTicks {
user: 0,
nice: 0,
system: 0,
idle: 0,
iowait: 0,
irq: 0,
softirq: 0,
steal: 0,
},
net_iface: net_iface.clone(),
net_rx,
net_tx,
last_cpu_pct: cpu_usage_pct,
last_iowait_pct: 0.0,
last_steal_pct: 0.0,
},
);
rates
};
Ok(HostInspection {
host_id: LOCAL_HOST_ID.to_string(),
sampled_at: Utc::now().to_rfc3339(),
uptime_seconds,
load_1: 0.0,
load_5: 0.0,
load_15: 0.0,
tcp_cc: None,
cpu_model,
cpu_cores,
cpu_usage_pct,
cpu_iowait_pct: 0.0,
cpu_steal_pct: 0.0,
mem_total_bytes,
mem_used_bytes,
mem_cached_bytes: 0,
swap_total_bytes: 0,
swap_used_bytes: 0,
disk_mount: primary_disk.mount,
disk_total_bytes: primary_disk.total_bytes,
disk_used_bytes: primary_disk.used_bytes,
disks,
net_iface,
net_rx_bytes_total: net_rx,
net_tx_bytes_total: net_tx,
net_rx_bps,
net_tx_bps,
})
}
}
fn compute_cpu_diff(a: &CpuTicks, b: &CpuTicks) -> Option<(f64, f64, f64)> {
let total_delta = b.total().saturating_sub(a.total());
if total_delta == 0 {
return None;
}
let idle_delta = b.idle_all().saturating_sub(a.idle_all());
let busy_delta = total_delta.saturating_sub(idle_delta);
let io_delta = b.iowait.saturating_sub(a.iowait);
let st_delta = b.steal.saturating_sub(a.steal);
Some((
((busy_delta as f64 / total_delta as f64) * 100.0).clamp(0.0, 100.0),
((io_delta as f64 / total_delta as f64) * 100.0).clamp(0.0, 100.0),
((st_delta as f64 / total_delta as f64) * 100.0).clamp(0.0, 100.0),
))
}
fn parse_cpu_ticks(s: &str) -> CpuTicks {
let mut it = s.split_whitespace().filter_map(|x| x.parse::<u64>().ok());
CpuTicks {
user: it.next().unwrap_or(0),
nice: it.next().unwrap_or(0),
system: it.next().unwrap_or(0),
idle: it.next().unwrap_or(0),
iowait: it.next().unwrap_or(0),
irq: it.next().unwrap_or(0),
softirq: it.next().unwrap_or(0),
steal: it.next().unwrap_or(0),
}
}
+233
View File
@@ -0,0 +1,233 @@
#![cfg_attr(
all(windows, not(debug_assertions)),
windows_subsystem = "windows"
)]
mod api;
mod crypto;
mod db;
#[cfg(windows)]
mod desktop;
mod files;
mod inspector;
mod mcp;
mod models;
mod ssh;
mod terminal;
use anyhow::Result;
use api::{build_api_router, AppState};
use axum::{
body::Body,
http::{header, StatusCode, Uri},
response::{IntoResponse, Response},
};
use crypto::MasterKey;
use db::Database;
use files::TransferManager;
use inspector::InspectorEngine;
use mcp::{run_stdio_bridge, sync_cli_mcp_configs, ExecTaskManager};
use rust_embed::RustEmbed;
use ssh::SshPool;
use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration};
use terminal::TerminalManager;
use tower_http::cors::{Any, CorsLayer};
use tracing::info;
#[derive(RustEmbed)]
#[folder = "frontend/dist/"]
struct EmbeddedFrontend;
fn resolve_data_dir() -> PathBuf {
let cwd_data = PathBuf::from("data");
if cwd_data.join("vps-switch.db").exists() {
return cwd_data;
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let exe_data = dir.join("data");
if exe_data.join("vps-switch.db").exists() {
return exe_data;
}
}
}
if let Ok(appdata) = std::env::var("APPDATA") {
return PathBuf::from(appdata).join("vps-switch").join("data");
}
cwd_data
}
/// 静态前端资源服务:优先读本机 frontend/dist(方便开发热更新),不存在时直接走二进制内嵌的 EmbeddedFrontend(单文件打包免外部依赖)
async fn serve_frontend_asset(uri: Uri) -> Response {
let raw_path = uri.path().trim_start_matches('/');
let rel_path = if raw_path.is_empty() {
"index.html"
} else {
raw_path
};
let disk_candidate = PathBuf::from("frontend/dist").join(rel_path);
if let Ok(bytes) = tokio::fs::read(&disk_candidate).await {
let mime = mime_guess::from_path(rel_path).first_or_octet_stream();
return Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime.as_ref())
.body(Body::from(bytes))
.unwrap();
}
if let Some(content) = EmbeddedFrontend::get(rel_path) {
let mime = mime_guess::from_path(rel_path).first_or_octet_stream();
return Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime.as_ref())
.body(Body::from(content.data.into_owned()))
.unwrap();
}
if let Some(index) = EmbeddedFrontend::get("index.html") {
return Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(index.data.into_owned()))
.unwrap();
}
(StatusCode::NOT_FOUND, "Not Found").into_response()
}
fn try_wakeup_existing_instance(port: u16) -> bool {
use std::io::{Read, Write};
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
let Ok(mut stream) = std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(120)) else {
return false;
};
let req = format!(
"POST /api/window/show HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
if stream.write_all(req.as_bytes()).is_err() {
return false;
}
let mut buf = [0u8; 128];
let _ = stream.read(&mut buf);
true
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let is_mcp_stdio = args.iter().any(|a| a == "--mcp-stdio" || a == "mcp");
let is_headless = args.iter().any(|a| a == "--headless" || a == "--server");
let port: u16 = std::env::var("PORT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(18800);
// 桌面模式下若已有实例在运行,直接唤醒已有主窗口并退出
#[cfg(windows)]
if !is_mcp_stdio && !is_headless && try_wakeup_existing_instance(port) {
return Ok(());
}
if !is_mcp_stdio {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "vps_switch=info,tower_http=warn".into()),
)
.init();
}
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let data_dir = resolve_data_dir();
let master_key = MasterKey::load_or_create(&data_dir)?;
let db = Arc::new(Database::open(&data_dir, master_key)?);
if db.count_ssh_hosts().unwrap_or(0) == 0 {
match db.import_from_1shell() {
Ok(n) if n > 0 && !is_mcp_stdio => info!("已自动从 1Shell 导入 {n} 台 VPS 主机"),
_ => {}
}
}
let exec_tasks = Arc::new(ExecTaskManager::new());
let state = Arc::new(AppState {
db,
ssh_pool: Arc::new(SshPool::new()),
terminals: Arc::new(TerminalManager::new()),
transfers: Arc::new(TransferManager::new()),
inspector: Arc::new(InspectorEngine::new()),
exec_tasks: exec_tasks.clone(),
sessions: std::sync::Mutex::new(std::collections::HashSet::new()),
port,
});
if is_mcp_stdio {
return rt.block_on(run_stdio_bridge(state, exec_tasks, port));
}
let sync_status = sync_cli_mcp_configs(port);
info!(
"MCP 自动配置同步: Claude Code={}, Codex={}",
sync_status.claude_code_synced, sync_status.codex_synced
);
// 启动时后台并发执行全机队快照预热
{
let bg_state = state.clone();
rt.spawn(async move {
use futures::stream::{self, StreamExt};
if let Ok(records) = bg_state.db.list_records() {
let active: Vec<_> = records.into_iter().filter(|r| !r.archived).collect();
stream::iter(active)
.for_each_concurrent(8, |rec| {
let st = bg_state.clone();
async move {
let _ = st
.inspector
.inspect_host(&st.ssh_pool, &st.db, &rec.id)
.await;
}
})
.await;
}
});
}
let bind_ip: std::net::IpAddr = std::env::var("HOST")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)));
let addr = SocketAddr::new(bind_ip, port);
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = build_api_router(state.clone())
.fallback(serve_frontend_asset)
.layer(cors);
#[cfg(windows)]
if !is_headless {
let rt_handle = rt.handle().clone();
rt.spawn(async move {
if let Ok(listener) = tokio::net::TcpListener::bind(addr).await {
info!("vps-switch desktop server listening on http://{addr}");
let _ = axum::serve(listener, app).await;
}
});
return desktop::run_desktop_window(state, rt_handle, port);
}
rt.block_on(async move {
let listener = tokio::net::TcpListener::bind(addr).await?;
info!("vps-switch headless server listening on http://{addr}");
axum::serve(listener, app).await?;
Ok::<(), anyhow::Error>(())
})
}
+1475
View File
File diff suppressed because it is too large. Load diff
+144
View File
@@ -0,0 +1,144 @@
use serde::{Deserialize, Serialize};
pub const LOCAL_HOST_ID: &str = "local";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct HostLink {
#[serde(default)]
pub id: String,
pub name: String,
pub url: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct OsInfo {
pub os: Option<String>,
pub distro_id: Option<String>,
pub version_id: Option<String>,
pub pretty_name: Option<String>,
pub arch: Option<String>,
pub kernel: Option<String>,
pub cpu_model: Option<String>,
pub cpu_cores: Option<u32>,
pub detected_at: Option<String>,
}
#[derive(Debug, Clone)]
pub struct HostRecord {
pub id: String,
pub name: String,
pub kind: String, // "local" | "ssh"
pub addr: String,
pub port: u16,
pub username: String,
pub auth_type: String, // "local" | "password" | "privateKey"
pub encrypted_password: Option<String>,
pub encrypted_private_key: Option<String>,
pub encrypted_passphrase: Option<String>,
pub jump_host_id: Option<String>,
pub role: Option<String>,
pub tags: Vec<String>,
pub links: Vec<HostLink>,
pub os_info: Option<OsInfo>,
pub pinned: bool,
pub favorited: bool,
pub archived: bool,
pub sort_order: i64,
pub status: String, // "online" | "offline" | "unknown"
pub latency_ms: Option<i64>,
pub last_connected_at: Option<String>,
pub last_error: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PublicHost {
pub id: String,
pub name: String,
pub kind: String,
pub addr: String,
pub port: u16,
pub username: String,
pub auth_type: String,
pub has_password: bool,
pub has_private_key: bool,
pub has_passphrase: bool,
pub jump_host_id: Option<String>,
pub jump_host_name: Option<String>,
pub role: Option<String>,
pub tags: Vec<String>,
pub links: Vec<HostLink>,
pub os_info: Option<OsInfo>,
pub pinned: bool,
pub favorited: bool,
pub archived: bool,
pub sort_order: i64,
pub status: String,
pub latency_ms: Option<i64>,
pub last_connected_at: Option<String>,
pub last_error: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HostUpsertPayload {
pub name: String,
#[serde(default)]
pub addr: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_username")]
pub username: String,
#[serde(default = "default_auth_type")]
pub auth_type: String,
pub password: Option<String>,
pub private_key: Option<String>,
pub passphrase: Option<String>,
pub jump_host_id: Option<String>,
pub role: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub links: Vec<HostLink>,
#[serde(default)]
pub pinned: bool,
#[serde(default)]
pub favorited: bool,
#[serde(default)]
pub archived: bool,
}
fn default_port() -> u16 {
22
}
fn default_username() -> String {
"root".to_string()
}
fn default_auth_type() -> String {
"password".to_string()
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HostMetaPatch {
pub pinned: Option<bool>,
pub favorited: Option<bool>,
pub archived: Option<bool>,
pub role: Option<String>,
pub tags: Option<Vec<String>>,
pub links: Option<Vec<HostLink>>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReorderPayload {
pub ordered_ids: Vec<String>,
}
+367
View File
@@ -0,0 +1,367 @@
use crate::{
crypto::MasterKey,
db::Database,
models::HostRecord,
};
use anyhow::{anyhow, Context, Result};
use russh::{
client::{self, AuthResult, Config, Handle, KeyboardInteractiveAuthResponse, Msg},
keys::{decode_secret_key, key::PrivateKeyWithHashAlg, PublicKeyOrCertificate},
Channel, ChannelMsg,
};
use russh_sftp::client::{Config as SftpConfig, SftpSession};
use std::{
collections::HashMap,
sync::Arc,
time::{Duration, Instant},
};
use tokio::sync::Mutex;
pub struct SshClientHandler;
impl client::Handler for SshClientHandler {
type Error = russh::Error;
async fn check_server_key(
&mut self,
_server_public_key: &PublicKeyOrCertificate,
) -> std::result::Result<bool, Self::Error> {
Ok(true)
}
}
/// 单主机 SSH 与 SFTP 连接池:
/// - 终端、执行、SFTP 文件浏览、高速上传下载、探针全部共用同一条底层 SSH 连接;
/// - 通道窗口拉大到 16 MiB + SFTP 64 路并发流水线(255 KiB 大包),彻底跑满跨境高 RTT 带宽。
#[derive(Default)]
pub struct SshPool {
sessions: Mutex<HashMap<String, Arc<Handle<SshClientHandler>>>>,
sftps: Mutex<HashMap<String, Arc<SftpSession>>>,
home_dirs: Mutex<HashMap<String, String>>,
dir_cache: Mutex<HashMap<(String, String), (Instant, crate::files::DirListing)>>,
}
impl SshPool {
pub fn new() -> Self {
Self {
sessions: Mutex::new(HashMap::new()),
sftps: Mutex::new(HashMap::new()),
home_dirs: Mutex::new(HashMap::new()),
dir_cache: Mutex::new(HashMap::new()),
}
}
pub async fn get_cached_dir(
&self,
host_id: &str,
path: &str,
max_age: Duration,
) -> Option<crate::files::DirListing> {
let guard = self.dir_cache.lock().await;
guard
.get(&(host_id.to_string(), path.to_string()))
.and_then(|(ts, listing)| {
if ts.elapsed() <= max_age {
Some(listing.clone())
} else {
None
}
})
}
pub async fn put_cached_dir(
&self,
host_id: &str,
req_path: &str,
listing: &crate::files::DirListing,
) {
let mut guard = self.dir_cache.lock().await;
let now = Instant::now();
guard.insert(
(host_id.to_string(), listing.path.clone()),
(now, listing.clone()),
);
if req_path != listing.path {
guard.insert(
(host_id.to_string(), req_path.to_string()),
(now, listing.clone()),
);
}
}
pub async fn invalidate_dir_cache(&self, host_id: &str) {
let mut guard = self.dir_cache.lock().await;
guard.retain(|(h, _), _| h != host_id);
}
pub async fn get_home_dir(&self, sftp: &SftpSession, host_id: &str) -> String {
{
let guard = self.home_dirs.lock().await;
if let Some(h) = guard.get(host_id) {
return h.clone();
}
}
let resolved = sftp
.canonicalize(".")
.await
.unwrap_or_else(|_| "/root".to_string());
self.home_dirs
.lock()
.await
.insert(host_id.to_string(), resolved.clone());
resolved
}
pub async fn get_or_connect(
&self,
host: &HostRecord,
jump_host: Option<&HostRecord>,
master_key: &MasterKey,
) -> Result<Arc<Handle<SshClientHandler>>> {
{
let guard = self.sessions.lock().await;
if let Some(existing) = guard.get(&host.id) {
if !existing.is_closed() {
return Ok(existing.clone());
}
}
}
let handle = connect_single_or_jump(host, jump_host, master_key).await?;
let arc_handle = Arc::new(handle);
{
let mut guard = self.sessions.lock().await;
guard.insert(host.id.clone(), arc_handle.clone());
}
Ok(arc_handle)
}
pub async fn open_session_channel(
&self,
host: &HostRecord,
jump_host: Option<&HostRecord>,
master_key: &MasterKey,
) -> Result<Channel<Msg>> {
let handle = self.get_or_connect(host, jump_host, master_key).await?;
match handle.channel_open_session().await {
Ok(ch) => Ok(ch),
Err(_) => {
self.invalidate_host(&host.id).await;
let fresh = self.get_or_connect(host, jump_host, master_key).await?;
fresh
.channel_open_session()
.await
.context("打开 SSH 会话通道失败")
}
}
}
pub async fn invalidate_host(&self, host_id: &str) {
self.sftps.lock().await.remove(host_id);
self.sessions.lock().await.remove(host_id);
}
pub async fn invalidate_sftp(&self, host_id: &str) {
self.sftps.lock().await.remove(host_id);
}
pub async fn get_or_open_sftp(
&self,
db: &Database,
host_id: &str,
) -> Result<Arc<SftpSession>> {
{
let guard = self.sftps.lock().await;
if let Some(existing) = guard.get(host_id) {
return Ok(existing.clone());
}
}
let (_record, channel) = open_host_channel(self, db, host_id).await?;
channel
.request_subsystem(true, "sftp")
.await
.context("请求 SFTP 子系统失败")?;
// 高速调优:利用 OpenSSH limits@openssh.com (255KiB 单包) + 64 并发读写流水线 (16 MiB 在途窗口)
let sftp_cfg = SftpConfig {
max_packet_len: 1024 * 1024,
max_concurrent_reads: 64,
max_concurrent_writes: 64,
max_write_packet_len: 261_120,
request_timeout_secs: 60,
};
let sftp = SftpSession::new_with_config(channel.into_stream(), sftp_cfg)
.await
.context("初始化 SFTP 会话失败")?;
let arc_sftp = Arc::new(sftp);
{
let mut guard = self.sftps.lock().await;
guard.insert(host_id.to_string(), arc_sftp.clone());
}
Ok(arc_sftp)
}
}
async fn authenticate_session(
session: &mut Handle<SshClientHandler>,
host: &HostRecord,
master_key: &MasterKey,
) -> Result<()> {
let user = if host.username.trim().is_empty() {
"root"
} else {
host.username.trim()
};
if host.auth_type == "privateKey" {
let key_pem = master_key
.decrypt(host.encrypted_private_key.as_deref())?
.ok_or_else(|| anyhow!("该主机未配置私钥"))?;
let passphrase = master_key.decrypt(host.encrypted_passphrase.as_deref())?;
let key_pair =
decode_secret_key(&key_pem, passphrase.as_deref()).context("解析 SSH 私钥失败")?;
let best_hash = session.best_supported_rsa_hash().await?.flatten();
let key_with_alg = PrivateKeyWithHashAlg::new(Arc::new(key_pair), best_hash);
match session.authenticate_publickey(user, key_with_alg).await? {
AuthResult::Success => return Ok(()),
AuthResult::Failure { .. } => return Err(anyhow!("SSH 私钥认证被服务器拒绝")),
}
}
let password = master_key
.decrypt(host.encrypted_password.as_deref())?
.ok_or_else(|| anyhow!("该主机未配置密码"))?;
match session.authenticate_password(user, &password).await? {
AuthResult::Success => return Ok(()),
AuthResult::Failure { .. } => {}
}
let mut ki = session
.authenticate_keyboard_interactive_start(user, None::<String>)
.await?;
for _ in 0..3 {
match ki {
KeyboardInteractiveAuthResponse::Success => return Ok(()),
KeyboardInteractiveAuthResponse::Failure { .. } => {
return Err(anyhow!("SSH 密码认证失败"));
}
KeyboardInteractiveAuthResponse::InfoRequest { prompts, .. } => {
let responses = prompts.iter().map(|_| password.clone()).collect();
ki = session
.authenticate_keyboard_interactive_respond(responses)
.await?;
}
}
}
Err(anyhow!("SSH 认证未通过"))
}
pub async fn connect_single_or_jump(
host: &HostRecord,
jump_host: Option<&HostRecord>,
master_key: &MasterKey,
) -> Result<Handle<SshClientHandler>> {
let mut cfg = Config::default();
// 关键性能优化:将 SSH Channel 窗口从默认 2MiB 提升至 16MiB,单包上限拉满 65535,
// 彻底消除跨境高 RTT 线路上传下载时等待 SSH_MSG_CHANNEL_WINDOW_ADJUST 造成的断流限速
cfg.window_size = 16 * 1024 * 1024;
cfg.maximum_packet_size = 65535;
cfg.channel_buffer_size = 1024;
cfg.inactivity_timeout = Some(Duration::from_secs(3600));
cfg.keepalive_interval = Some(Duration::from_secs(25));
cfg.nodelay = true;
let config = Arc::new(cfg);
if let Some(jump) = jump_host {
let jump_addr = format!("{}:{}", jump.addr.trim(), jump.port);
let mut jump_session = tokio::time::timeout(
Duration::from_secs(10),
client::connect(config.clone(), jump_addr.as_str(), SshClientHandler),
)
.await
.context("连接跳板机超时")?
.context("连接跳板机失败")?;
authenticate_session(&mut jump_session, jump, master_key)
.await
.context("跳板机认证失败")?;
let direct_channel = tokio::time::timeout(
Duration::from_secs(10),
jump_session.channel_open_direct_tcpip(
host.addr.trim(),
host.port as u32,
"127.0.0.1",
0,
),
)
.await
.context("通过跳板机建立隧道超时")?
.context("通过跳板机建立隧道失败")?;
let stream = direct_channel.into_stream();
let mut target_session = tokio::time::timeout(
Duration::from_secs(10),
client::connect_stream(config, stream, SshClientHandler),
)
.await
.context("目标主机 SSH 握手超时")?
.context("目标主机 SSH 握手失败")?;
authenticate_session(&mut target_session, host, master_key).await?;
Ok(target_session)
} else {
let addr = format!("{}:{}", host.addr.trim(), host.port);
let mut session = tokio::time::timeout(
Duration::from_secs(8),
client::connect(config, addr.as_str(), SshClientHandler),
)
.await
.context("SSH 连接超时")?
.context("SSH 连接失败")?;
authenticate_session(&mut session, host, master_key).await?;
Ok(session)
}
}
pub async fn exec_short_command(session: &Handle<SshClientHandler>, cmd: &str) -> Result<String> {
let mut channel = session.channel_open_session().await?;
channel.exec(true, cmd).await?;
let mut out = Vec::new();
let collect = async {
while let Some(msg) = channel.wait().await {
match msg {
ChannelMsg::Data { data } => out.extend_from_slice(&data),
ChannelMsg::ExtendedData { data, .. } => out.extend_from_slice(&data),
ChannelMsg::Eof | ChannelMsg::Close => break,
_ => {}
}
}
};
let _ = tokio::time::timeout(Duration::from_secs(5), collect).await;
Ok(String::from_utf8_lossy(&out).trim().to_string())
}
pub async fn open_host_channel(
pool: &SshPool,
db: &Database,
host_id: &str,
) -> Result<(HostRecord, Channel<Msg>)> {
let record = db
.get_record(host_id)?
.ok_or_else(|| anyhow!("主机不存在: {host_id}"))?;
let jump = match record.jump_host_id.as_deref() {
Some(jid) => db.get_record(jid)?,
None => None,
};
let channel = pool
.open_session_channel(&record, jump.as_ref(), db.master_key())
.await?;
let _ = db.update_check_result(&record.id, true, None, None, None);
Ok((record, channel))
}
+978
View File
@@ -0,0 +1,978 @@
use crate::{
db::Database,
models::LOCAL_HOST_ID,
ssh::{open_host_channel, SshPool},
};
use alacritty_terminal::{
event::VoidListener,
grid::Dimensions,
index::{Column, Line, Point},
term::{Config as TermConfig, Term, TermMode},
vte::ansi::Processor,
};
use anyhow::{Context, Result};
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use russh::ChannelMsg;
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, VecDeque},
io::{Read, Write},
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use tokio::sync::{broadcast, mpsc};
use uuid::Uuid;
const RING_BUFFER_CAPACITY: usize = 2 * 1024 * 1024;
#[derive(Clone, Copy)]
struct TermDimensions {
cols: usize,
lines: usize,
}
impl Dimensions for TermDimensions {
fn total_lines(&self) -> usize {
self.lines
}
fn screen_lines(&self) -> usize {
self.lines
}
fn columns(&self) -> usize {
self.cols
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaneMeta {
pub id: String,
pub host_id: String,
pub cols: u16,
pub rows: u16,
pub cwd: Option<String>,
pub title: Option<String>,
pub running: bool,
pub last_exit_code: Option<i32>,
pub bracketed_paste: bool,
pub headless: bool,
pub exited: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScreenSnapshot {
pub pane_id: String,
pub host_id: String,
pub state: String, // "idle" | "waiting_input" | "running" | "exited"
pub cursor_line: String,
pub screen: String,
pub cwd: Option<String>,
pub last_exit_code: Option<i32>,
pub headless: bool,
pub exited: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum UiEvent {
RevealTerminal {
host_id: String,
pane: PaneMeta,
reason: Option<String>,
},
ShowMainWindow,
}
#[derive(Clone)]
pub enum PaneBroadcast {
Output(Vec<u8>),
Meta(PaneMeta),
}
pub enum PaneCommand {
Input(Vec<u8>),
Paste(String),
Resize(u16, u16),
Close,
}
pub struct PaneInternalState {
pub term: Term<VoidListener>,
pub processor: Processor,
pub ring_buffer: VecDeque<u8>,
pub cols: u16,
pub rows: u16,
pub cwd: Option<String>,
pub title: Option<String>,
pub running: bool,
pub last_exit_code: Option<i32>,
pub headless: bool,
pub exited: bool,
pub output_seq: u64,
pub last_output_at: Instant,
osc_tail: Vec<u8>,
}
impl PaneInternalState {
fn new(cols: u16, rows: u16, initial_cwd: Option<String>, headless: bool) -> Self {
let dims = TermDimensions {
cols: cols.max(2) as usize,
lines: rows.max(1) as usize,
};
let term = Term::new(TermConfig::default(), &dims, VoidListener);
Self {
term,
processor: Processor::new(),
ring_buffer: VecDeque::with_capacity(64 * 1024),
cols,
rows,
cwd: initial_cwd,
title: None,
running: false,
last_exit_code: None,
headless,
exited: false,
output_seq: 0,
last_output_at: Instant::now(),
osc_tail: Vec::new(),
}
}
pub fn feed_bytes(&mut self, data: &[u8]) -> bool {
self.processor.advance(&mut self.term, data);
self.output_seq = self.output_seq.wrapping_add(1);
self.last_output_at = Instant::now();
let clean_for_replay = strip_terminal_queries(data);
if clean_for_replay.len() >= RING_BUFFER_CAPACITY {
self.ring_buffer.clear();
let start = clean_for_replay.len() - RING_BUFFER_CAPACITY;
self.ring_buffer.extend(&clean_for_replay[start..]);
} else if !clean_for_replay.is_empty() {
let overflow = (self.ring_buffer.len() + clean_for_replay.len())
.saturating_sub(RING_BUFFER_CAPACITY);
if overflow > 0 {
self.ring_buffer.drain(..overflow);
}
self.ring_buffer.extend(&clean_for_replay);
}
self.scan_osc_sequences(data)
}
pub fn resize(&mut self, cols: u16, rows: u16) {
self.cols = cols.max(2);
self.rows = rows.max(1);
let dims = TermDimensions {
cols: self.cols as usize,
lines: self.rows as usize,
};
self.term.resize(dims);
}
pub fn bracketed_paste_enabled(&self) -> bool {
self.term.mode().contains(TermMode::BRACKETED_PASTE)
}
/// 直接从 alacritty_terminal::Term 二维屏幕网格读取最后 max_lines 行与光标所在行(零 ANSI 噪音)
pub fn read_screen_grid(&self, max_lines: usize) -> (String, String) {
let bottom = self.term.bottommost_line();
let top = self.term.topmost_line();
let last_col = self.term.last_column();
let cursor_pt = self.term.grid().cursor.point;
let cursor_line = self
.term
.bounds_to_string(
Point::new(cursor_pt.line, Column(0)),
Point::new(cursor_pt.line, last_col),
)
.trim_end()
.to_string();
let requested = max_lines.clamp(5, 200) as i32;
let start_line = std::cmp::max(top, Line(bottom.0 - requested + 1));
let raw_screen = self.term.bounds_to_string(
Point::new(start_line, Column(0)),
Point::new(bottom, last_col),
);
let lines: Vec<&str> = raw_screen.lines().collect();
let mut last_non_empty = lines.len();
while last_non_empty > 0 && lines[last_non_empty - 1].trim().is_empty() {
last_non_empty -= 1;
}
let trimmed_screen = lines[..last_non_empty].join("\n");
(trimmed_screen, cursor_line)
}
fn scan_osc_sequences(&mut self, chunk: &[u8]) -> bool {
let mut changed = false;
self.osc_tail.extend_from_slice(chunk);
if self.osc_tail.len() > 4096 {
let keep = 1024;
let drain_len = self.osc_tail.len() - keep;
self.osc_tail.drain(..drain_len);
}
let mut idx = 0;
let mut consumed = 0;
while idx + 2 <= self.osc_tail.len() {
if self.osc_tail[idx] == 0x1b && self.osc_tail[idx + 1] == b']' {
let start = idx + 2;
let mut end = None;
let mut next_pos = start;
while next_pos < self.osc_tail.len() {
if self.osc_tail[next_pos] == 0x07 {
end = Some((next_pos, next_pos + 1));
break;
}
if next_pos + 1 < self.osc_tail.len()
&& self.osc_tail[next_pos] == 0x1b
&& self.osc_tail[next_pos + 1] == b'\\'
{
end = Some((next_pos, next_pos + 2));
break;
}
next_pos += 1;
}
if let Some((payload_end, after_end)) = end {
let osc_owned = std::str::from_utf8(&self.osc_tail[start..payload_end])
.ok()
.map(str::to_owned);
if let Some(osc_str) = osc_owned {
if self.handle_osc_payload(&osc_str) {
changed = true;
}
}
consumed = after_end;
idx = after_end;
} else {
break;
}
} else {
idx += 1;
}
}
if consumed > 0 {
self.osc_tail.drain(..consumed);
} else if let Some(esc_pos) = self.osc_tail.iter().rposition(|&b| b == 0x1b) {
if esc_pos > 0 {
self.osc_tail.drain(..esc_pos);
}
} else {
self.osc_tail.clear();
}
changed
}
fn handle_osc_payload(&mut self, osc: &str) -> bool {
if let Some(rest) = osc.strip_prefix("7;") {
if let Some(path_part) = rest.strip_prefix("file://") {
if let Some(slash_idx) = path_part.find('/') {
let raw_path = &path_part[slash_idx..];
let decoded = percent_decode(raw_path);
let clean = if decoded.len() >= 3
&& decoded.starts_with('/')
&& decoded.as_bytes()[2] == b':'
{
decoded[1..].to_string()
} else {
decoded
};
if !clean.is_empty() && self.cwd.as_deref() != Some(&clean) {
self.cwd = Some(clean);
return true;
}
}
}
}
if let Some(rest) = osc.strip_prefix("133;") {
if rest.starts_with('A') {
if self.running {
self.running = false;
return true;
}
} else if rest.starts_with('C') {
if !self.running {
self.running = true;
return true;
}
} else if let Some(done_rest) = rest.strip_prefix('D') {
let code = done_rest
.strip_prefix(';')
.and_then(|s| s.split(';').next())
.and_then(|s| s.trim().parse::<i32>().ok());
let prev_running = self.running;
let prev_code = self.last_exit_code;
self.running = false;
if code.is_some() {
self.last_exit_code = code;
}
return prev_running || prev_code != self.last_exit_code;
}
}
if let Some(title) = osc.strip_prefix("0;").or_else(|| osc.strip_prefix("2;")) {
let trimmed = title.trim();
if !trimmed.is_empty() {
let mut changed = false;
if self.title.as_deref() != Some(trimmed) {
self.title = Some(trimmed.to_string());
changed = true;
}
if let Some((_, dir_part)) = trimmed.split_once(": ") {
let dir = dir_part.trim();
if (dir.starts_with('/') || dir.starts_with('~'))
&& self.cwd.as_deref() != Some(dir)
{
self.cwd = Some(dir.to_string());
changed = true;
}
}
if self.running {
self.running = false;
changed = true;
}
return changed;
}
}
false
}
}
fn percent_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(h), Some(l)) = (
(bytes[i + 1] as char).to_digit(16),
(bytes[i + 2] as char).to_digit(16),
) {
out.push(((h << 4) | l) as u8);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).to_string()
}
fn strip_terminal_queries(data: &[u8]) -> Vec<u8> {
const QUERIES: &[&[u8]] = &[
b"\x1b[c",
b"\x1b[0c",
b"\x1b[>c",
b"\x1b[>0c",
b"\x1b[=c",
b"\x1b[=0c",
b"\x1b[5n",
b"\x1b[6n",
b"\x1b[?6n",
];
let mut out = Vec::with_capacity(data.len());
let mut i = 0;
'outer: while i < data.len() {
if data[i] == 0x1b {
for q in QUERIES {
if data[i..].starts_with(q) {
i += q.len();
continue 'outer;
}
}
}
out.push(data[i]);
i += 1;
}
out
}
pub fn looks_like_interactive_prompt(line: &str) -> bool {
let lower = line.trim().to_lowercase();
if lower.is_empty() {
return false;
}
let patterns = [
"[y/n]",
"[yes/no]",
"(y/n)",
"(yes/no",
"continue?",
"proceed?",
"password:",
"password for",
"passphrase",
"enter ",
"press enter",
"overwrite",
"are you sure",
"请确认",
"请输入",
];
if patterns.iter().any(|p| lower.contains(p)) {
return true;
}
(lower.ends_with('?') || lower.ends_with(':')) && !looks_like_shell_prompt(line)
}
pub fn looks_like_shell_prompt(line: &str) -> bool {
let t = line.trim();
if t.is_empty() {
return false;
}
if t.starts_with("PS ") && t.ends_with('>') {
return true;
}
if (t.contains('@') || t.starts_with('[') || t.contains(":~") || t.contains(":/"))
&& (t.ends_with('#') || t.ends_with('$') || t.ends_with('%'))
{
return true;
}
t.ends_with(":~#") || t.ends_with(":~$") || t == "#" || t == "$"
}
pub struct PaneHandle {
pub id: String,
pub host_id: String,
pub state: Arc<Mutex<PaneInternalState>>,
pub cmd_tx: mpsc::UnboundedSender<PaneCommand>,
pub broadcast_tx: broadcast::Sender<PaneBroadcast>,
}
impl PaneHandle {
pub fn snapshot_meta(&self) -> PaneMeta {
let guard = self.state.lock().unwrap();
PaneMeta {
id: self.id.clone(),
host_id: self.host_id.clone(),
cols: guard.cols,
rows: guard.rows,
cwd: guard.cwd.clone(),
title: guard.title.clone(),
running: guard.running,
last_exit_code: guard.last_exit_code,
bracketed_paste: guard.bracketed_paste_enabled(),
headless: guard.headless,
exited: guard.exited,
}
}
pub fn replay_bytes(&self) -> Vec<u8> {
let guard = self.state.lock().unwrap();
let (a, b) = guard.ring_buffer.as_slices();
let mut out = Vec::with_capacity(a.len() + b.len());
out.extend_from_slice(a);
out.extend_from_slice(b);
out
}
pub fn read_screen_snapshot(&self, lines: usize) -> ScreenSnapshot {
let guard = self.state.lock().unwrap();
let (screen, cursor_line) = guard.read_screen_grid(lines);
let state_str = if guard.exited {
"exited"
} else if looks_like_interactive_prompt(&cursor_line) {
"waiting_input"
} else if looks_like_shell_prompt(&cursor_line) || !guard.running {
"idle"
} else {
"running"
};
ScreenSnapshot {
pane_id: self.id.clone(),
host_id: self.host_id.clone(),
state: state_str.to_string(),
cursor_line,
screen,
cwd: guard.cwd.clone(),
last_exit_code: guard.last_exit_code,
headless: guard.headless,
exited: guard.exited,
}
}
/// 向 PTY 发送输入并智能等待:
/// - 若命令执行完毕回到提示符,立即返回 `idle`;
/// - 若脚本中途停在 `[Y/n]` / `yes/no` / `password:` 等交互提问超过 600ms,立即返回 `waiting_input`(绝不卡死!)。
pub async fn send_and_wait(
&self,
bytes: Vec<u8>,
max_lines: usize,
wait_ms: u64,
) -> ScreenSnapshot {
let seq_before = self.state.lock().unwrap().output_seq;
let _ = self.cmd_tx.send(PaneCommand::Input(bytes));
let deadline = Instant::now() + Duration::from_millis(wait_ms.clamp(200, 60_000));
loop {
tokio::time::sleep(Duration::from_millis(80)).await;
let (exited, seq_now, quiet_ms, running, cursor_line) = {
let g = self.state.lock().unwrap();
let (_, cl) = g.read_screen_grid(5);
(
g.exited,
g.output_seq,
g.last_output_at.elapsed().as_millis() as u64,
g.running,
cl,
)
};
if exited {
break;
}
if seq_now > seq_before {
if looks_like_interactive_prompt(&cursor_line) && quiet_ms >= 550 {
break;
}
if (!running || looks_like_shell_prompt(&cursor_line)) && quiet_ms >= 350 {
break;
}
}
if Instant::now() >= deadline {
break;
}
}
self.read_screen_snapshot(max_lines)
}
}
pub struct TerminalManager {
panes: Mutex<HashMap<String, Arc<PaneHandle>>>,
ui_events_tx: broadcast::Sender<UiEvent>,
}
impl Default for TerminalManager {
fn default() -> Self {
Self::new()
}
}
impl TerminalManager {
pub fn new() -> Self {
let (ui_events_tx, _) = broadcast::channel(128);
Self {
panes: Mutex::new(HashMap::new()),
ui_events_tx,
}
}
pub fn subscribe_ui_events(&self) -> broadcast::Receiver<UiEvent> {
self.ui_events_tx.subscribe()
}
pub fn notify_show_main_window(&self) {
let _ = self.ui_events_tx.send(UiEvent::ShowMainWindow);
}
pub fn list_panes(&self, host_id_filter: Option<&str>, include_headless: bool) -> Vec<PaneMeta> {
let guard = self.panes.lock().unwrap();
guard
.values()
.filter(|p| host_id_filter.map(|h| p.host_id == h).unwrap_or(true))
.map(|p| p.snapshot_meta())
.filter(|m| include_headless || !m.headless)
.collect()
}
pub fn get_pane(&self, pane_id: &str) -> Option<Arc<PaneHandle>> {
let guard = self.panes.lock().unwrap();
guard.get(pane_id).cloned()
}
pub fn find_active_pane_for_host(&self, host_id: &str) -> Option<Arc<PaneHandle>> {
let guard = self.panes.lock().unwrap();
guard
.values()
.find(|p| p.host_id == host_id && !p.state.lock().unwrap().exited)
.cloned()
}
/// 将一个无头终端(或已存在的终端)唤起到前端界面,供用户亲自输入隐私密码或手动操作
pub fn reveal_pane_in_ui(&self, pane_id: &str, reason: Option<String>) -> Option<PaneMeta> {
let pane = self.get_pane(pane_id)?;
{
let mut g = pane.state.lock().unwrap();
g.headless = false;
}
let meta = pane.snapshot_meta();
let _ = self.ui_events_tx.send(UiEvent::RevealTerminal {
host_id: pane.host_id.clone(),
pane: meta.clone(),
reason,
});
Some(meta)
}
pub fn close_pane(&self, pane_id: &str) -> bool {
let removed = {
let mut guard = self.panes.lock().unwrap();
guard.remove(pane_id)
};
if let Some(pane) = removed {
let _ = pane.cmd_tx.send(PaneCommand::Close);
true
} else {
false
}
}
pub async fn create_pane(
&self,
pool: &SshPool,
db: &Database,
host_id: &str,
cols: u16,
rows: u16,
initial_cwd: Option<String>,
headless: bool,
) -> Result<PaneMeta> {
let cols = cols.clamp(20, 500);
let rows = rows.clamp(5, 200);
let pane_id = format!("pane_{}", Uuid::new_v4());
let handle = if host_id == LOCAL_HOST_ID {
spawn_local_conpty_pane(
pane_id.clone(),
host_id.to_string(),
cols,
rows,
initial_cwd,
headless,
)?
} else {
spawn_ssh_pty_pane(
pool,
db,
pane_id.clone(),
host_id.to_string(),
cols,
rows,
initial_cwd,
headless,
)
.await?
};
let meta = handle.snapshot_meta();
{
let mut guard = self.panes.lock().unwrap();
guard.insert(pane_id, handle);
}
Ok(meta)
}
}
fn build_meta_from_guard(pane_id: &str, host_id: &str, guard: &PaneInternalState) -> PaneMeta {
PaneMeta {
id: pane_id.to_string(),
host_id: host_id.to_string(),
cols: guard.cols,
rows: guard.rows,
cwd: guard.cwd.clone(),
title: guard.title.clone(),
running: guard.running,
last_exit_code: guard.last_exit_code,
bracketed_paste: guard.bracketed_paste_enabled(),
headless: guard.headless,
exited: guard.exited,
}
}
fn spawn_local_conpty_pane(
pane_id: String,
host_id: String,
cols: u16,
rows: u16,
initial_cwd: Option<String>,
headless: bool,
) -> Result<Arc<PaneHandle>> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.context("创建本机 ConPTY 失败")?;
let mut cmd = CommandBuilder::new("powershell.exe");
let init_hook = r#"$global:__vps_orig_prompt = $function:prompt; function global:prompt { $ec = if ($?) { 0 } else { if ($LASTEXITCODE) { $LASTEXITCODE } else { 1 } }; $p = ($executionContext.SessionState.Path.CurrentLocation.Path -replace '\\','/'); [Console]::Write("$([char]27)]133;D;$ec$([char]7)$([char]27)]7;file://localhost/$p$([char]7)$([char]27)]133;A$([char]7)"); & $global:__vps_orig_prompt }"#;
cmd.args(["-NoLogo", "-NoExit", "-Command", init_hook]);
cmd.env("TERM", "xterm-256color");
cmd.env("TERM_PROGRAM", "vps-switch");
if let Some(ref cwd) = initial_cwd {
cmd.cwd(cwd);
} else if let Ok(home) = std::env::var("USERPROFILE") {
cmd.cwd(home);
}
let mut child = pair
.slave
.spawn_command(cmd)
.context("启动本机 PowerShell 失败")?;
drop(pair.slave);
let mut reader = pair
.master
.try_clone_reader()
.context("克隆 PTY reader 失败")?;
let mut writer = pair.master.take_writer().context("获取 PTY writer 失败")?;
let master = pair.master;
let state = Arc::new(Mutex::new(PaneInternalState::new(
cols,
rows,
initial_cwd,
headless,
)));
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<PaneCommand>();
let (broadcast_tx, _) = broadcast::channel::<PaneBroadcast>(512);
let handle = Arc::new(PaneHandle {
id: pane_id.clone(),
host_id: host_id.clone(),
state: state.clone(),
cmd_tx,
broadcast_tx: broadcast_tx.clone(),
});
let state_read = state.clone();
let bcast_read = broadcast_tx.clone();
let pid_read = pane_id.clone();
let hid_read = host_id.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
let chunk = buf[..n].to_vec();
let meta_opt = {
let mut guard = state_read.lock().unwrap();
let changed = guard.feed_bytes(&chunk);
if changed {
Some(build_meta_from_guard(&pid_read, &hid_read, &guard))
} else {
None
}
};
let _ = bcast_read.send(PaneBroadcast::Output(chunk));
if let Some(m) = meta_opt {
let _ = bcast_read.send(PaneBroadcast::Meta(m));
}
}
}
}
let final_meta = {
let mut guard = state_read.lock().unwrap();
guard.exited = true;
guard.running = false;
build_meta_from_guard(&pid_read, &hid_read, &guard)
};
let _ = bcast_read.send(PaneBroadcast::Meta(final_meta));
});
let state_cmd = state.clone();
let bcast_cmd = broadcast_tx.clone();
tokio::spawn(async move {
while let Some(cmd) = cmd_rx.recv().await {
match cmd {
PaneCommand::Input(bytes) => {
if bytes.contains(&b'\r') || bytes.contains(&b'\n') {
let mut guard = state_cmd.lock().unwrap();
guard.running = true;
}
let _ = writer.write_all(&bytes);
let _ = writer.flush();
}
PaneCommand::Paste(text) => {
let payload = encode_safe_paste(&state_cmd, &text);
let _ = writer.write_all(&payload);
let _ = writer.flush();
}
PaneCommand::Resize(c, r) => {
let meta = {
let mut guard = state_cmd.lock().unwrap();
guard.resize(c, r);
build_meta_from_guard(&pane_id, &host_id, &guard)
};
let _ = master.resize(PtySize {
rows: r.max(1),
cols: c.max(2),
pixel_width: 0,
pixel_height: 0,
});
let _ = bcast_cmd.send(PaneBroadcast::Meta(meta));
}
PaneCommand::Close => {
let _ = child.kill();
break;
}
}
}
});
Ok(handle)
}
async fn spawn_ssh_pty_pane(
pool: &SshPool,
db: &Database,
pane_id: String,
host_id: String,
cols: u16,
rows: u16,
initial_cwd: Option<String>,
headless: bool,
) -> Result<Arc<PaneHandle>> {
let (_record, mut channel) = open_host_channel(pool, db, &host_id).await?;
channel
.request_pty(
false,
"xterm-256color",
cols as u32,
rows as u32,
0,
0,
&[],
)
.await
.context("请求 SSH PTY 失败")?;
channel
.request_shell(true)
.await
.context("启动远端 Shell 失败")?;
let state = Arc::new(Mutex::new(PaneInternalState::new(
cols,
rows,
initial_cwd.or_else(|| Some("~".to_string())),
headless,
)));
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<PaneCommand>();
let (broadcast_tx, _) = broadcast::channel::<PaneBroadcast>(512);
let handle = Arc::new(PaneHandle {
id: pane_id.clone(),
host_id: host_id.clone(),
state: state.clone(),
cmd_tx,
broadcast_tx: broadcast_tx.clone(),
});
let state_loop = state.clone();
let bcast_loop = broadcast_tx.clone();
let pid_loop = pane_id.clone();
let hid_loop = host_id.clone();
tokio::spawn(async move {
loop {
tokio::select! {
msg_opt = channel.wait() => {
match msg_opt {
Some(ChannelMsg::Data { data }) | Some(ChannelMsg::ExtendedData { data, .. }) => {
let chunk = data.to_vec();
let meta_opt = {
let mut guard = state_loop.lock().unwrap();
let changed = guard.feed_bytes(&chunk);
if changed {
Some(build_meta_from_guard(&pid_loop, &hid_loop, &guard))
} else {
None
}
};
let _ = bcast_loop.send(PaneBroadcast::Output(chunk));
if let Some(m) = meta_opt {
let _ = bcast_loop.send(PaneBroadcast::Meta(m));
}
}
Some(ChannelMsg::ExitStatus { exit_status }) => {
let mut guard = state_loop.lock().unwrap();
guard.last_exit_code = Some(exit_status as i32);
}
Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => {
break;
}
_ => {}
}
}
cmd_opt = cmd_rx.recv() => {
match cmd_opt {
Some(PaneCommand::Input(bytes)) => {
if bytes.contains(&b'\r') || bytes.contains(&b'\n') {
let mut guard = state_loop.lock().unwrap();
guard.running = true;
}
if channel.data(&bytes[..]).await.is_err() {
break;
}
}
Some(PaneCommand::Paste(text)) => {
let payload = encode_safe_paste(&state_loop, &text);
if channel.data(&payload[..]).await.is_err() {
break;
}
}
Some(PaneCommand::Resize(c, r)) => {
let meta = {
let mut guard = state_loop.lock().unwrap();
guard.resize(c, r);
build_meta_from_guard(&pid_loop, &hid_loop, &guard)
};
let _ = channel.window_change(c.max(2) as u32, r.max(1) as u32, 0, 0).await;
let _ = bcast_loop.send(PaneBroadcast::Meta(meta));
}
Some(PaneCommand::Close) | None => {
let _ = channel.close().await;
break;
}
}
}
}
}
let final_meta = {
let mut guard = state_loop.lock().unwrap();
guard.exited = true;
guard.running = false;
build_meta_from_guard(&pid_loop, &hid_loop, &guard)
};
let _ = bcast_loop.send(PaneBroadcast::Meta(final_meta));
});
Ok(handle)
}
fn encode_safe_paste(state: &Arc<Mutex<PaneInternalState>>, text: &str) -> Vec<u8> {
let bracketed = state.lock().unwrap().bracketed_paste_enabled();
let sanitized: String = text
.chars()
.filter(|&c| c != '\x1b' && c != '\0')
.collect();
if bracketed {
let mut out = Vec::with_capacity(sanitized.len() + 12);
out.extend_from_slice(b"\x1b[200~");
out.extend_from_slice(sanitized.as_bytes());
out.extend_from_slice(b"\x1b[201~");
out
} else {
sanitized.into_bytes()
}
}