清理:移除 RDP 遗留资料并纳管产品文档与运维工具

变更详情:清理 RDP 遗留文档与临时文件,纳管产品文档与运维工具脚本
This commit is contained in:
宋一鸣 2026-10-29 17:44:25 +08:00
parent 26fde0369e
commit 7fdcb53dd9
14 changed files with 759 additions and 100 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,33 +0,0 @@
# FreeRDP 3 客户端 API 参考(Ubuntu 24.04 / freerdp3-dev)
> 用途:kiosk-shell 内嵌 FreeRDP 客户端时使用,避免拉起外部进程(开发方案 3.2 节)。
> 记录时间:2026-10-23
## 包名
- 运行时:`libfreerdp-client3-3`、`libfreerdp3-3`
- 开发:`freerdp3-dev`(Ubuntu 24.04 提供 `freerdp3-dev` 与 `libfreerdp-client3-3-dev`)
- pkg-config 名称:`freerdp3`、`freerdp3-client`(安装后以 `pkg-config --list-all | grep freerdp` 实测为准)
## 关键头文件
| 头文件 | 用途 |
|---|---|
| `freerdp/client.h` | 客户端主入口、freerdp_client_context_new / freerdp_client_context_free |
| `freerdp/freerdp.h` | 核心上下文 |
| `freerdp/settings.h` | 连接参数设置(freerdp_settings_new / set) |
| `freerdp/channels/cliprdr.h` | 剪贴板通道 |
| `freerdp/gdi/gfx.h` | GFX 图形管线(AVC420/AVC444/RFX 能力) |
| `freerdp/crypto/tls.h` | TLS |
## 最小连接流程(伪代码)
```c
freerdp_client_context* ctx = freerdp_client_context_new();
RdpClientContext* cctx = ...;
freerdp_settings_new(0); // 释放: freerdp_settings_free
freerdp_set_pointer / freerdp_set_keyboard ... // 输入回调
ctx->Connect = ...; // 事件表
freerdp_client_context_new / connect
```
## 注意事项
- `/gfx` 命令行语法在 3.32.1 上必须用 `|` 分隔的能力组合(如 `progressive|RFX|AVC420|AVC444`),
单独 `AVC420` 会解析失败。API 侧对应 `gfx` 能力位设置。

View File

@ -1,67 +0,0 @@
# FreeRDP 3 内嵌客户端实现笔记(Ubuntu 24.04 / freerdp3-dev 3.32.1)
> 对应《VDI 云桌面软件开发方案》3.2 / 3.3 节:以 libfreerdp3 API 内嵌(非拉起外部进程)。
> 记录时间:2026-10-23,实测于 RK3528 盒子(Armbian / Ubuntu Noble / aarch64)
## 1. API 结构(与 FreeRDP 2.x 差异很大)
| 用途 | FreeRDP 3 写法 |
|---|---|
| 创建实例 | `freerdp* instance = freerdp_new();` |
| 建立上下文 | `freerdp_context_new_ex(instance, settings)` |
| 上下文 | `instance->context`(`rdpContext*`,公开结构体) |
| 事件回调 | `instance->PostConnect` / `instance->PostDisconnect` / `instance->AuthenticateEx` |
| 更新回调 | `instance->context->update->BitmapUpdate` / `->EndPaint` |
| GDI | `gdi_init(instance, PIXEL_FORMAT_BGRX32)` |
| 取帧 | `context->gdi->primary_buffer` + `context->gdi->stride` |
设置项键名(与 2.x 不同):
`FreeRDP_ServerHostname` / `FreeRDP_Username` / `FreeRDP_Password` / `FreeRDP_Domain` /
`FreeRDP_DesktopWidth` / `FreeRDP_DesktopHeight` / `FreeRDP_ColorDepth` / `FreeRDP_Fullscreen` /
`FreeRDP_IgnoreCertificate` / `FreeRDP_GfxProgressive` / `FreeRDP_GfxH264` / `FreeRDP_GfxAVC444` …
## 2. 三个必须踩过才知道的坑
### 坑一:gdi_init 必须在 PostConnect 里调用
在 `freerdp_context_new_ex()` 之后立刻调用 `gdi_init()` 会 **SIGSEGV**
(`gdi_init_ex` 段错误)。官方 x11 客户端(`client/X11/xf_client.c` 的 `xf_post_connect`)
把 `gdi_init` 放在 PostConnect 回调内,因为 GDI 依赖连接协商后的桌面尺寸与颜色格式。
### 坑二:不能用 instance->ContextSize 扩展 rdpContext
常见做法是把自定义指针塞进扩展的 rdpContext,但 `gdi_init_ex()` 会按 `ContextSize`
重新分配 context 缓冲区,扩展字段随即失效 → 段错误。
改用静态注册表按 `freerdp*` 记录会话对象;GFX 通道回调则用官方的
`RdpgfxClientContext.custom` 字段。
### 坑三:NLA 认证必须传空字符串而非 nullptr
Windows 端启用 NLA(CredSSP)时,认证回调里把密码置为 `nullptr` 会导致
`SEC_E_NO_CREDENTIALS [0x8009030e]`。必须传 `strdup("")`。
认证回调签名:
`BOOL (*pAuthenticateEx)(freerdp*, char** username, char** password, char** domain, rdp_auth_reason)`
## 3. /gfx 语法(命令行与设置项的对应)
FreeRDP 3.32.1 的 `/gfx` **不接受单个管道名**,必须是 `|` 分隔的能力组合:
```
/gfx:progressive|RFX|AVC420|AVC444 ← 可解析
/gfx:AVC420 ← parsing failed [-1000]
/gfx:AVC444 ← parsing failed [-1000]
```
API 侧对应 `FreeRDP_GfxProgressive` / `FreeRDP_GfxH264` / `FreeRDP_GfxAVC444` 等布尔项。
## 4. 像素格式
`PIXEL_FORMAT_BGRX32` 的内存布局是 **B,G,R,X** 四字节/像素。
QImage 没有对应格式,输出前需手动做 BGR→RGB 转换。
注意 `PIXEL_FORMAT_*` 是计算宏 `FREERDP_PIXEL_FORMAT(...)`,**不能硬编码数值**。
## 5. 后续演进(对应 freerdp-rk 分支)
当前实现是 CPU 侧 GDI 缓冲 → Qt 绘制。开发方案 3.3 节要求改为:
```text
FreeRDP3 解复用(gfx 通道) → GStreamer h264parse → mppvideodec(VPU硬解, zero-copy DMABuf)
→ waylandsink(DRM plane)
```
盒子已具备 `/dev/mpp_service`(VPU)与 `/dev/rga`,但缺 `mppvideodec` GStreamer 插件
(Ubuntu 官方源没有,需 Rockchip BSP / Armbian 额外源或自研)。

View File

@ -0,0 +1,39 @@
@echo off
REM ============================================================
REM MttVDD 虚拟显示驱动 - 一键安装(管理员运行)
REM 用法:右键本文件 -> 以管理员身份运行
REM ============================================================
setlocal
cd /d "%~dp0"
echo [1/4] 关闭可能占用驱动的程序...
sc stop SunshineService >nul 2>&1
taskkill /f /im sunshine.exe >nul 2>&1
echo [2/4] 安装驱动签名证书...
certutil -addstore -f "Root" "Virtual_Display_Driver.cer"
if errorlevel 1 (
echo [错误] 证书安装失败,请确认以管理员身份运行
pause
exit /b 1
)
echo [3/4] 安装 MttVDD 驱动...
pnputil /add-driver "MttVDD.inf" /install
if errorlevel 1 (
echo [警告] 驱动安装返回错误,尝试另一种方式...
rundll32.exe setupapi.dll,InstallHinfSection DefaultInstall 132 .\MttVDD.inf
)
echo [4/4] 显示当前虚拟显示器状态...
pnputil /enum-devices /class Display
echo.
echo ============================================================
echo 安装完成。请重启 Windows 后:
echo 1) 设置 - 显示 - 找到 MttVDD 虚拟显示器
echo 2) 分辨率选 1920x1080,刷新率选 60Hz
echo 3) 重启 SunshineService 服务
echo 重启方式:开始菜单搜索 services.msc -> SunshineService -> 重新启动
echo ============================================================
pause

View File

@ -0,0 +1,82 @@
# RDP 与 Moonlight 实测对比(2026-10-28)
## 测试环境
```text
终端:RK3528 PRO DDR4,Armbian 25.05,aarch64,LightDM + Xorg
显示器:1920×1440 @ 75Hz
云桌面:VM 100(win10-test),Proxmox VE 9.2.21,节点 starhe
终端到云桌面:192.168.1.188 -> 192.168.1.70,同一局域网 WiFi
```
服务端显示环境改造(本次关键前提):
```text
改造前:Microsoft Basic Display Adapter,1920×1080 @ 1Hz
改造后:Virtual Display Driver 8.35.11.365,1920×1080 @ 60Hz
```
改造前 Sunshine 抓不到稳定帧(1Hz),改造后可正常编码。
## 对比数据
| 指标 | RDP(当前 kiosk-shell) | Moonlight 1080p30 | Moonlight 1080p60 |
|---|---|---|---|
| 协议/编码 | RDP GFX,CPU 软解 | H.264 / libx264 软编 | H.264 / libx264 软编 |
| 分辨率 | 1920×1440 | 1920×1080 | 1920×1080 |
| 终端 CPU | 约 111%(单核近满载) | 约 26% | 约 26% |
| 终端内存 | 约 232 MB | 约 192 MB | 约 165 MB |
| 终端温度 | 约 58~65℃ | 约 56℃ | 约 53℃ |
| 实际帧率 | 无可靠统计(观感为低帧+卷帘) | 12.83 FPS | 22.79 FPS |
| 网络丢包 | 未测 | 0.00% | 0.00% |
| 网络抖动丢帧 | 未测 | 0.08% | 15.28% |
| 网络延迟 | 未测 | 2 ms | 6 ms |
| 主机处理延迟 | 未测 | 14.4 / 27.6 / 平均 18.1 ms | 18.8 / 78.3 / 平均 38.2 ms |
| 平均渲染耗时 | 未测 | 3.34 ms | 46.26 ms |
| 画面观感 | 卷帘、翻页 | 完整清晰,无撕裂 | 完整清晰,无撕裂 |
## 结论
1. **卷帘问题不是 RDP 协议的限制,是当前 RDP 客户端实现路径的问题。**
Moonlight 在同一台盒子、同一台云桌面、同一网络下画面完整清晰,无卷帘无撕裂,
抓屏确认 1080p 画面文字锐利、任务栏时间正确。
2. **Moonlight 的瓶颈在服务端,不在终端,也不<E4B99F><E4B88D><EFBFBD>网络。**
1080p30 时网络丢包 0.00%、延迟 2 ms、渲染 3.34 ms、终端 CPU 26%,
但实际帧率仅 12.83 FPS;1080p60 时主机处理延迟升到平均 38.2 ms、渲染 46.26 ms。
服务端当前使用 libx264 软件编码(VM 内无 NVIDIA 直通,`nvidia-smi` 不存在),
4 核 CPU 同时承担 1080p 编码与桌面捕获,是帧率上限的真正原因。
3. **RDP 路径的 CPU 代价约为 Moonlight 的 4 倍**(111% vs 26%),
因为 RDP 走 `FreeRDP GDI -> CPU 内存复制 -> QImage/QPixmap -> QLabel -> Xorg`,
而 Moonlight 只做一次解码和一次窗口呈现。
4. **RK3528 VPU 硬解在当前 moonlight-qt 6.2.0 下不可用。**
`h264_v4l2m2m` / `hevc_v4l2m2m` / `hevc_v4l2request` 均报
`Could not find a valid device`,即使盒子存在 /dev/mpp_service、/dev/rga 和
/dev/dri/renderD128。需 Rockchip MPP FFmpeg 补丁或 V4L2 request 驱动。
## 后续建议
优先级从高到低:
1. **服务端 GPU 直通**:将宿主机 GTX 965M(PCI 01:00.0,IOMMU Group 2 独占)
直通给 VM 100,使 Sunshine 可用 NVENC 硬编码。预期把 1080p 帧率从 13 FPS
提升到 60 FPS,同时释放服务端 CPU。需先确认内核 `intel_iommu=on`、
`vfio-pci` 已绑定,以及该笔记本 GPU 的 vBIOS ROM 提取。
2. **消除帧率随请求升高而抖动**(60 FPS 时抖动丢帧 15.28%):服务端稳定后复测。
3. **RK3528 硬解适配**:解决 V4L2 request/m2m 设备缺失,这是达成 4K 的前提。
4. **协议选型建议**:
- 若 GPU 直通可行,Moonlight 在 CAD/3D/视频场景优于 RDP,建议按产品方案采用双协议:
普通办公用 RDP,GPU/CAD/3D 用 Moonlight;
- 同时保留并优化 RDP 路径的 Weston + MPP/VPU + DMABUF/DRM 零拷贝改造,
使 RDP 在办公场景达到可接受体验。
5. 当前 Moonlight 未接 VPU,1080p 软件解码仅 26% CPU 但 4K 会超出能力,
需在 4K 目标确定后优先解决硬解。
## 附:本次变更
- VM 100:安装 Virtual Display Driver(MttVDD),提供 1920×1080@60Hz 虚拟显示器。
- VM 100:安装 Sunshine v2026.914.233613,Web UI 账号 admin / vdi12345。
- 终端:安装 moonlight-qt 6.2.0,已与 Sunshine 完成配对。
- 终端:kiosk-shell 自启动已恢复(~/.config/autostart/vdi-kiosk.desktop)。

View File

@ -0,0 +1,74 @@
# Sunshine 显卡环境配置指引(转给操作 VM 的同事)
## 背景
我们在 VM 100(win10-test,192.168.1.70)里装了 Sunshine 用于 Moonlight 串流验证。当前卡在:VM 没有可用 GPU,Windows 只有「Microsoft 基本显示适配器」,分辨率 1024×768、刷新率 1Hz,Sunshine 无法按 30/60fps 捕获编码。
不需要动 Proxmox,只需要在 Windows 里完成两件事:装虚拟显示器驱动 + 确认 Sunshine 配置。
## 第 1 步:安装虚拟显示器驱动(IddSampleDriver)
让 Windows 出现一个虚拟显卡,可自定义分辨率和刷新率:
1. 下载虚拟显示驱动包(ParrotGeek 的 IddSampleDriver,或等效的「Virtual Display Driver」)。
常用地址:https://github.com/itsmikethetech/Virtual-Display-Driver/releases
(如果 GitHub 打不开,用镜像:把链接前面加 https://gh-proxy.com/ )
2. 解压后右键 `IddSampleDriver.inf` →「安装」。
3. 重启 Windows。
4. 装好后设备管理器会出现「IddSampleDriver Device」,显示设置里会多出一个虚拟显示器。
5. 把虚拟显示器设置为:
- 分辨率:1920×1080(先用 1080p,稳定后再试 2K)
- 刷新率:60Hz(关键!当前 1Hz 是主要问题)
- 如果看到多显示器,把虚拟屏设为「扩展这些显示器」, Sunshine 默认抓主屏;也可以只保留虚拟屏。
## 第 2 步:确认 NVIDIA 独显是否可用(如果宿主已直通/有驱动)
打开 PowerShell 执行:
```powershell
nvidia-smi
```
- 如果能输出显卡信息(GTX 965M):很好,说明 VM 内能用 NVIDIA。
- 请安装 GeForce 驱动(如未装),然后告知我们,我们把 Sunshine 切到 NVENC 硬编码。
- 如果提示找不到命令或没有 NVIDIA 设备:跳过此步,走纯软件编码 + 虚拟显示器路线(也够验证流畅度)。
## 第 3 步:检查 Sunshine 服务状态
Sunshine 已装在 `C:\Program Files\Sunshine\`,服务名 `SunshineService`。
PowerShell 执行:
```powershell
Get-Service SunshineService
Restart-Service SunshineService
```
重启服务后再看日志确认识别到新显示器:
```powershell
Get-Content "C:\Program Files\Sunshine\config\sunshine.log" -Tail 60
```
需要看到的关键行(类似):
```
Desktop resolution [1920x1080]
Display refresh rate [60 Hz]
```
## 第 4 步:告知我们结果
只需回三条信息:
1. 虚拟显示器是否装好,分辨率/刷新率是多少;
2. `nvidia-smi` 是否有输出;
3. sunshine.log 最后几十行里的 Desktop resolution / Display refresh rate 数值。
我们这边会立刻重新发起 Moonlight 串流测试并采集性能数据。
## 备注
- Web UI(可选):https://192.168.1.70:47990 账号 admin / 密码 vdi12345
- 装驱动需要重启 VM,重启后 SunshineService 会自启,无需其他操作。
- Sunshine 的串流端口 47984/47989/47990/48010 已确认开放,防火墙无需改动。

20
docs/vdd_settings.xml Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<display_config>
<target_display>
<target_x>0</target_x>
<target_y>0</target_y>
<target_width>1920</target_width>
<target_height>1080</target_height>
<target_refresh_rate>60</target_refresh_rate>
</target_display>
<adapter_index>0</adapter_index>
<modes>
<mode>
<width>1920</width>
<height>1080</height>
<refresh_rate>60</refresh_rate>
</mode>
</modes>
</display_config>
</root>

View File

@ -0,0 +1,287 @@
# VDI Web 管理控制台 MVP 实施方案
> 版本:v0.1(功能先行,UI 稿待项目经理提供)
> 日期:2026-10-28
> 依据:`VDI云桌面软件开发方案(1).docx` 第 2、4、5 节及 `VDI云桌面完整方案-云管端.docx` 管理平台模块表。
## 1. 先明确两个“登录”
本项目有两个不同的界面,必须分开:
| 界面 | 使用者 | 运行位置 | 职责 |
|---|---|---|---|
| 终端 kiosk-shell | 云桌面最终用户 | RK3528 盒子本地全屏 | 首配、设备绑定、用户登录、选择/进入被授权桌面、会话状态 |
| Web 管理控制台 console | 超级管理员、运维、只读人员 | PC 浏览器 | 终端、桌面池、用户绑定、GPU、监控、OTA、审计管理 |
正式文档中的 `console/` 指第二项。它不应运行在盒子上,也不应直接发起 RDP/Moonlight;它通过平台 API 管理资源和下发策略。
“盒子开机直连 Moonlight”只能作为当前串流技术验证,不是最终产品登录流程。最终终端流程应恢复为:
```text
盒子开机
-> kiosk-shell 全屏终端登录/首配页
-> vdt-agent 注册并获取终端身份
-> 用户登录或终端绑定校验
-> 平台返回该用户可用的桌面列表及短期连接信息
-> 用户选择桌面
-> kiosk-shell 按 protocol=rdp|moonlight 启动会话
-> 断开后回桌面列表/登录页
```
## 2. Web 控制台 MVP 目标
第一版目标不是做漂亮的大屏,而是跑通一个可演示、可替换 UI、可接真实后端的后台系统:
1. 用户能登录和退出;
2. 根据角色控制菜单和按钮;
3. 管理员能查看终端、桌面、用户及绑定关系;
4. 能对终端下发命令、对桌面执行开关机/重置;
5. 能查看基础状态、操作结果和审计记录;
6. 后端未完成时使用 Mock API,后端接口上线后不改页面业务逻辑即可切换。
## 3. 技术栈与工程结构
按定版文档执行:Vue 3 + Vite + TypeScript 严格模式 + Element Plus。
建议补充:Pinia(状态)、Vue Router、Axios、Vitest、Playwright、MSW(Mock API)。
```text
console/
├─ src/
│ ├─ api/ # OpenAPI 生成 SDK 的薄封装
│ ├─ assets/
│ ├─ components/ # 领域无关组件
│ ├─ layouts/ # 登录布局、控制台布局
│ ├─ mocks/ # MSW handlers 与演示数据
│ ├─ router/ # 路由、鉴权守卫、RBAC meta
│ ├─ stores/ # auth、app、notification
│ ├─ types/ # 页面 ViewModel;API 类型由生成器管理
│ ├─ views/
│ │ ├─ login/
│ │ ├─ dashboard/
│ │ ├─ terminals/
│ │ ├─ desktops/
│ │ ├─ users/
│ │ ├─ gpu/
│ │ ├─ monitoring/
│ │ ├─ ota/
│ │ └─ audit/
│ ├─ App.vue
│ └─ main.ts
├─ e2e/
├─ public/
├─ .env.development
├─ .env.mock
├─ package.json
└─ vite.config.ts
```
约束:页面不能直接拼 URL 或写 Axios;所有网络调用只经过 `api/`。这样 Mock 与真实后端可无感切换。
## 4. 页面与优先级
### M0:登录与应用框架
- 登录页:用户名、密码、登录按钮、错误状态、加载状态;
- 控制台壳:侧边导航、顶部栏、面包屑、当前账号、退出;
- 401 自动清理会话并回登录;
- 403 权限提示;
- 路由级 RBAC:`super_admin`、`operator`、`viewer`;
- 开发环境使用 Mock 登录,生产环境只调用真实 API。
### M1:核心演示闭环
1. 仪表盘:在线终端、活动会话、运行桌面、告警数量;
2. 终端管理:列表、搜索、状态筛选、详情、重启、重置会话;
3. 桌面管理:列表、状态筛选、开机、关机、重启、重置;
4. 用户与绑定:用户列表、用户详情、终端/桌面绑定与解绑;
5. 审计日志:操作者、动作、目标、结果、时间、请求 ID。
M1 完成后已经可以给项目经理演示完整业务流程。
### M2:完整文档范围
- 桌面池:模板、容量、回收策略;
- GPU 池:节点/PCI 地址/型号/状态/绑定桌面;
- 监控:会话在线数、FPS、RTT、节点水位、告警;
- OTA 中心:版本、批次、灰度比例、进度、失败终端;
- CSV 用户/绑定批量导入向导;
- 配置模板与配置版本下发。
## 5. 登录与权限方案
### 登录接口
```http
POST /api/v1/auth/login
Content-Type: application/json
{
"username": "admin",
"password": "******"
}
```
建议响应:
```json
{
"accessToken": "jwt",
"expiresIn": 3600,
"user": {
"id": "usr_001",
"username": "admin",
"displayName": "系统管理员",
"role": "super_admin"
}
}
```
建议第一版 access token 仅存内存;刷新 token 使用 `HttpOnly + Secure + SameSite` Cookie。若后端尚未支持 refresh,MVP 可暂存 `sessionStorage`,但不要长期使用 `localStorage` 保存高权限令牌。
### 权限矩阵
| 能力 | super_admin | operator | viewer |
|---|---:|---:|---:|
| 查看所有页面 | 是 | 是 | 是 |
| 终端远程命令 | 是 | 是 | 否 |
| 桌面生命周期操作 | 是 | 是 | 否 |
| 用户/绑定修改 | 是 | 否 | 否 |
| GPU 绑定 | 是 | 否 | 否 |
| OTA 发布/灰度 | 是 | 是 | 否 |
| 只读监控/审计 | 是 | 是 | 是 |
前端 RBAC 只是交互约束,真正授权必须由 `api-gateway` 再验证,不能只隐藏按钮。
## 6. 第一版 API 契约
文档已明确的接口优先沿用:
```text
POST /api/v1/auth/login
GET /api/v1/users
POST /api/v1/desktop/assign
POST /api/v1/pool/{id}/action
GET /api/v1/gpu/cards
POST /api/v1/gpu/bind
GET /api/v1/monitor/query
```
为控制台 MVP 补齐建议接口(需后端确认并写入 OpenAPI):
```text
GET /api/v1/auth/me
POST /api/v1/auth/logout
GET /api/v1/terminals
GET /api/v1/terminals/{id}
POST /api/v1/terminals/{id}/commands
GET /api/v1/desktops
GET /api/v1/desktops/{id}
POST /api/v1/desktops/{id}/actions
GET /api/v1/bindings
DELETE /api/v1/bindings/{id}
GET /api/v1/audit-logs
```
所有列表统一查询参数:`page`、`pageSize`、`keyword`、`status`、`sort`;统一响应:
```json
{
"items": [],
"page": 1,
"pageSize": 20,
"total": 0,
"requestId": "req_xxx"
}
```
所有命令型接口必须返回 `commandId`,页面展示“已提交”,再轮询或通过 WebSocket/MQTT 网关显示最终结果,不能把 HTTP 200 当作设备已经执行成功。
## 7. Mock 先行策略
后端还没完成时,前端不应硬编码页面数据。使用 MSW 模拟真实 HTTP:
- 固定 3 个测试账号,对应三角色;
- 终端包含在线/离线/会话中/升级中;
- 桌面包含运行/停止/错误/任务中;
- 所有命令先进入 `pending`,1–3 秒后变成 `success` 或 `failed`;
- 登录失败、401、403、网络超时、空列表都要有测试场景;
- `VITE_API_MODE=mock|real` 切换数据源。
项目经理 UI 稿到后,Mock 数据仍然可用于逐页视觉验收。
## 8. 与终端登录流程的接口边界
管理员 Web 控制台不直接把 VM 密码写进终端,也不让终端调用 Proxmox API。
建议终端用户登录流程由 `auth-svc` 单独提供:
```text
POST /api/v1/terminal/session/login
GET /api/v1/terminal/session/desktops
POST /api/v1/terminal/session/connect-ticket
```
`connect-ticket` 返回短期、一次性连接信息:
```json
{
"desktopId": "desk_001",
"protocol": "moonlight",
"endpoint": "192.168.1.70",
"app": "Desktop",
"display": { "width": 1920, "height": 1080, "fps": 60 },
"ticket": "short-lived-token",
"expiresAt": "2026-10-28T16:30:00+08:00"
}
```
这部分不是 `console/` 页面,但必须和 Web 控制台的“用户/终端/桌面绑定”共享同一套后端数据。
## 9. UI 稿到来前的页面设计原则
第一版只做稳定工作台,不做营销式页面:
- 左侧导航 + 顶部工具栏 + 主内容区;
- 列表优先,详情使用独立页或抽屉;
- 状态使用图标/标签,不依赖单一颜色;
- 危险操作二次确认;
- 所有异步操作显示提交、执行中、成功、失败四态;
- 页面级空态、错误态、无权限态必须完整;
- 颜色、字号、间距集中为设计 token,避免 UI 稿到后逐页改 CSS;
- 表格列、筛选区、表单字段以配置驱动,减少视觉改版成本。
## 10. 验收标准
M1 应达到:
1. 三种角色都能登录,菜单/按钮权限正确;
2. 刷新页面仍能恢复会话,令牌过期自动回登录;
3. 终端、桌面、用户/绑定、审计五条流程可演示;
4. 命令有执行中与最终结果,不出现“点击无反馈”;
5. API 全走统一 SDK/适配层,无页面直连 Axios;
6. Vitest 覆盖 auth store、路由守卫、权限判断;
7. Playwright 覆盖登录、终端命令、桌面操作、用户绑定四条主路径;
8. `npm run lint`、`npm run typecheck`、`npm run test`、`npm run build` 全通过。
## 11. 推荐实施顺序
```text
第 1 步:初始化 console 工程、路由、布局、设计 token
第 2 步:OpenAPI 初稿 + API SDK 生成 + MSW Mock
第 3 步:登录、会话恢复、RBAC、错误处理
第 4 步:终端管理 + 桌面管理
第 5 步:用户与绑定 + 审计日志
第 6 步:仪表盘聚合数据
第 7 步:Playwright E2E 与生产构建
第 8 步:项目经理 UI 稿到后替换视觉层
第 9 步:接真实 Go API,关闭 Mock
```
## 12. 当前决策建议
- 现在先开发 `console/` 的 M0 + M1,不等 UI 稿;
- 同时为 `terminal/kiosk-shell` 恢复“登录/桌面列表后连接”的产品流程,不再把开机直连 Moonlight 当最终形态;
- Web 控制台第一版只接 Mock,API 契约先和后端负责人确认;
- Moonlight/RDP 统一抽象成终端连接票据的 `protocol` 字段,Web 控制台只管理策略,不直接操控串流客户端;
- UI 稿到后只调整布局组件与 design tokens,业务路由、权限、API、测试保留。

View File

@ -0,0 +1,29 @@
#!/bin/sh
set -u
LOG_DIR="$HOME/vdi-terminal/logs"
LOG_FILE="$LOG_DIR/overnight-$(date +%Y%m%d).log"
mkdir -p "$LOG_DIR"
log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" "$*" >> "$LOG_FILE"; }
log "monitor_start pid=$$ interval=60s"
while :; do
now=$(date '+%Y-%m-%d %H:%M:%S %z')
shell_pid=$(pgrep -f '^/home/armbian/vdi-terminal/bin/kiosk-shell$' | head -n 1 || true)
monitor_pid=$(pgrep -f '^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor' | head -n 1 || true)
if [ -n "$shell_pid" ]; then
ps_line=$(ps -o pcpu=,pmem=,rss=,etime= -p "$shell_pid" | tr '\n' ' ')
else
ps_line="missing"
fi
temp=$(awk '{printf "%.1f", $1/1000}' /sys/class/thermal/thermal_zone0/temp 2>/dev/null || printf "unknown")
mem=$(awk '/MemTotal:|MemAvailable:|SwapFree:/{printf "%s=%sKB ",$1,$2}' /proc/meminfo 2>/dev/null || true)
heartbeat="missing"
if [ -f /tmp/vdi-kiosk.heartbeat ]; then
heartbeat=$(stat -c '%Y' /tmp/vdi-kiosk.heartbeat 2>/dev/null || printf 'unknown')
fi
rdp=$(ss -tnp 2>/dev/null | grep '192.168.1.70:3389' | tr '\n' ';' || true)
starts=$(grep -c '主进程已启动' /tmp/vdi-kiosk-monitor.log 2>/dev/null || printf 0)
exits=$(grep -c '主进程已退出' /tmp/vdi-kiosk-monitor.log 2>/dev/null || printf 0)
crashes=$(grep -ciE 'segmentation fault|bus error|double free|invalid pointer|aborted|terminate called' /tmp/vdi-kiosk-monitor.log 2>/dev/null || printf 0)
log "sample now=$now shell_pid=${shell_pid:-missing} monitor_pid=${monitor_pid:-missing} ps=[$ps_line] temp_c=$temp heartbeat_epoch=$heartbeat rdp=[$rdp] starts=$starts exits=$exits crashes=$crashes $mem"
sleep 60
done

89
tools/pve_term_exec.py Normal file
View File

@ -0,0 +1,89 @@
"""Run a command on the PVE node via the same VNC term channel the web console uses.
Usage: python3 pve_term_exec.py "<command>"
Reads ticket from /tmp/pve_t.txt (refreshed automatically if expired).
Prints the command output captured from the terminal stream.
"""
import base64
import json
import ssl
import struct
import sys
import time
import urllib.parse
import urllib.request
HOST = "https://192.168.1.51:8006"
NODE = "starhe"
CTX = ssl.create_default_context()
CTX.check_hostname = False
CTX.verify_mode = ssl.CERT_NONE
def http(path, data=None, ticket=None, csrf=None):
body = urllib.parse.urlencode(data).encode() if data is not None else None
req = urllib.request.Request(HOST + path, body)
if ticket:
req.add_header("Cookie", "PVEAuthCookie=" + ticket)
if csrf:
req.add_header("CSRFPreventionToken", csrf)
with urllib.request.urlopen(req, context=CTX, timeout=30) as r:
raw = r.read()
return json.loads(raw)["data"] if raw else None
def login():
d = http("/api2/json/access/ticket",
{"username": "root@pam", "password": "yx66668888"})
return d["ticket"], d["CSRFPreventionToken"]
def run_cmd(command, timeout=60):
ticket, csrf = login()
# 1. open a term proxy (VNC channel to node shell)
r = http("/api2/json/nodes/%s/termproxy" % NODE, {}, ticket, csrf)
port, ticket_vnc = r["port"], r["ticket"]
user, realm_sep, _ = "root", "@", "pam"
import socket
sock = socket.create_connection(("192.168.1.51", port), timeout=10)
# PVE handshake: username@realm\n ticket\n binary handshake
sock.sendall(b"root@pam\n" + ticket_vnc.encode() + b"\n")
gh = sock.recv(16)
maj, mino = struct.unpack("BB", gh[0:2])
said = sock.recv(16 * 4)
# client sends its own handshake
ch = struct.pack("BB", maj, mino) + b"PVEVNC Client 00" + b"\x00" * 0
sock.sendall(ch)
# 2. run the command in the shell by typing it and reading output
marker = "PVEDONE%s" % int(time.time())
payload = command + " ; echo " + marker + "\n"
time.sleep(0.5)
sock.sendall(payload.encode())
buf = b""
deadline = time.time() + timeout
while time.time() < deadline:
try:
sock.settimeout(2)
chunk = sock.recv(65536)
if not chunk:
break
buf += chunk
if marker.encode() in buf.replace(b"\x1b", b"").replace(b"[?2004l", b""):
break
except socket.timeout:
if marker.encode() in buf:
break
continue
sock.close()
# strip terminal control chars roughly and the echoed command
text = buf.decode("utf-8", "replace")
text = text.replace(marker, "")
lines = [l for l in text.split("\r\n")]
return "\n".join(lines)
if __name__ == "__main__":
print(run_cmd(sys.argv[1]))

55
tools/pve_vm.py Normal file
View File

@ -0,0 +1,55 @@
import json
import ssl
import sys
import urllib.parse
import urllib.request
HOST = "https://192.168.1.51:8006"
CTX = ssl.create_default_context()
CTX.check_hostname = False
CTX.verify_mode = ssl.CERT_NONE
def api(path, data=None, ticket=None, csrf=None, json_body=None):
body = None
headers = {}
if json_body is not None:
body = json.dumps(json_body).encode()
headers["Content-Type"] = "application/json"
elif data is not None:
body = urllib.parse.urlencode(data).encode()
req = urllib.request.Request(HOST + path, body, headers)
if ticket:
req.add_header("Cookie", "PVEAuthCookie=" + ticket)
if csrf:
req.add_header("CSRFPreventionToken", csrf)
with urllib.request.urlopen(req, context=CTX, timeout=40) as r:
raw = r.read()
return json.loads(raw)["data"] if raw else None
def agent(ticket, csrf, command, timeout=90):
r = api("/api2/json/nodes/starhe/qemu/100/agent/exec",
json_body={"command": command}, ticket=ticket, csrf=csrf)
pid = r["pid"]
for _ in range(timeout // 2):
import time
time.sleep(2)
st = api("/api2/json/nodes/starhe/qemu/100/agent/exec-status?pid=%d" % pid,
ticket=ticket)
if st.get("exited"):
return st.get("out-data") or "", st.get("err-data") or ""
return "TIMEOUT", ""
if __name__ == "__main__":
d = api("/api2/json/access/ticket",
{"username": "root@pam", "password": "yx66668888"})
ticket, csrf = d["ticket"], d["CSRFPreventionToken"]
ps = sys.argv[1]
out, err = agent(ticket, csrf, [
"C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
"-NoProfile", "-Command", ps])
print(out)
if err:
print("ERR:", err[:300])

84
tools/xwd2png.py Normal file
View File

@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Convert an XWD dump (from xwd) into a PNG, without external image libraries.
Usage: xwd -root -silent | python3 xwd2png.py out.png
"""
import struct
import sys
import zlib
def read_header(data):
fields = struct.unpack(">25I", data[:100])
names = [
"header_size", "file_version", "pixmap_format", "pixmap_depth",
"pixmap_width", "pixmap_height", "xoffset", "byte_order",
"bitmap_unit", "bitmap_bit_order", "bitmap_pad", "bits_per_pixel",
"bytes_per_line", "visual_class", "red_mask", "green_mask",
"blue_mask", "bits_per_rgb", "colormap_entries", "ncolors",
"window_width", "window_height", "window_x", "window_y",
"window_bdrwidth",
]
return dict(zip(names, fields))
def write_png(path, width, height, rgb_rows):
raw = b"".join(b"\x00" + row for row in rgb_rows)
def chunk(tag, payload):
return (struct.pack(">I", len(payload)) + tag + payload +
struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF))
header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
png = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) +
chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b""))
with open(path, "wb") as handle:
handle.write(png)
def main():
out_path = sys.argv[1] if len(sys.argv) > 1 else "out.png"
data = sys.stdin.buffer.read()
head = read_header(data)
width = head["pixmap_width"]
height = head["pixmap_height"]
bpp = head["bits_per_pixel"]
stride = head["bytes_per_line"]
offset = head["header_size"] + head["ncolors"] * 12
pixels = data[offset:]
if bpp == 32:
rows = []
for y in range(height):
line = pixels[y * stride:y * stride + width * 4]
out = bytearray(width * 3)
for x in range(width):
src = x * 4
dst = x * 3
out[dst] = line[src + 2]
out[dst + 1] = line[src + 1]
out[dst + 2] = line[src]
rows.append(bytes(out))
elif bpp == 24:
rows = []
for y in range(height):
line = pixels[y * stride:y * stride + width * 3]
out = bytearray(width * 3)
for x in range(width):
src = x * 3
dst = x * 3
out[dst] = line[src + 2]
out[dst + 1] = line[src + 1]
out[dst + 2] = line[src]
rows.append(bytes(out))
else:
sys.stderr.write("unsupported bits_per_pixel=%s\n" % bpp)
return 2
write_png(out_path, width, height, rows)
print("%s %dx%d" % (out_path, width, height))
return 0
if __name__ == "__main__":
sys.exit(main())