feat: initial commit of vps-switch core modules and desktop frontend
This commit is contained in:
commit
d2348d0c00
38 files changed
+19943
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+41
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# vps-switch
|
||||
|
||||
一个轻量级 VPS 管理工具:先把 VPS 仓库、终端、文件浏览器、文件上传下载、探针五大核心板块做到极致,最后将这些现成链路加上护栏工具化为 MCP Server。
|
||||
|
||||
范围、六大板块路线与技术栈见 [docs/plan.md](docs/plan.md)。不在那里的东西不进仓库。
|
||||
+111
@@ -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 |
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1488
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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 |
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+140
@@ -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, ¶ms, &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)?))
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+144
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user